Assign object to object/array in PHP - php

Long time reader, first time asker. I'm experienced with things like Java/C but PHP is new to me.
I'm having an issue where an assignment doesn't assign to where I'd expect it to.
I'm getting an array from a MySQL database via Eloquent methods, in particular:
$result= TableA::where('tableA.id', '=', $id)
->with('tableB.tableC')
->get();
For reference, printing $result out looks like this:
[{"id":105, /*TableA fields*/, "tableB":null},
{"id":106, /*TableA fields*/, "tableB":null},
{"id":107, /*TableA fields*/, "tableB":{/*tableB fields*/, "tableC":
{"id":104, /*TableC fields*/}}},
{"id":108, /*TableA fields*/, "tableB":{/*tableB fields*/, "tableC":
{"id":105, /*TableC fields*/}}}]
In some cases a TableA tuple will have an associated record in tableB and hence tableC, other times there isn't an associated record in tableB. If there isn't an associated record I want to go through and make a temporary "dummy" record to pass through instead of passing through null. The code I'm using to do so is:
for ($i=0; $i < count($result); $i++)
{
if($result[$i]["tableB"] == null)
{
Log:info($result); //Print line A
$result[$i]["tableB"] = OtherController::makeDummyTableB(); //Assignment line
Log::info($result); //Print line B
Log::info($result[$i]["tableB"]); //Print line C
}
}
The problem is that the assignment line doesn't assign to the "tableB" field in the object/array returned in $result. Printing $result out at print line A and B gives the same result, with "tableB" being null for the first two records. Print line C however gives the output I'm expecting, which is the dummy record I'm creating
{"tableC":{/*TableC fields*/}}
So the assignment is doing something, but it's not assigning to the field in $result that's already there, and instead is assigning it to somewhere else (That doesn't show up when I attempt to display it)
If anyone could let me know what my current code is actually doing, and how to have it do what I'm expecting (replace "tableB":null with "tableB":{"tableC":{/*TableC fields*/}} ) it'd be much appreciated

Eloquent models have a lot of "magic" going on in the background. The fields from the table are loaded into an attributes property, and the relationships are loaded into a relations property.
The issue you're running into is that tableB is a relationship field, not a table field. Its data is stored in the relations property. The relations property, however, is not directly modifiable the way you are attempting. When you attempt to modify it using $result[$i]["tableB"], that code is actually modifying the tableB field in the attributes property.
Then there is the issue of getting the data. When you attempt to read the data directly using $result[$i]["tableB"], it will first look in the attributes property, and if it isn't found there, then it will look in the relations property.
However, when you dump the entire object using Log::info($result);, any data in the relations property overwrites the data in the attributes property. So, after a direct assignment using $result[$i]["tableB"] = 'asdf', Log::info($result) will not show the change (since relations overwrites attributes), whereas Log::info($result[$i]["tableB"]) will show the change (since it looks at attributes first).
So, analyzing your code, we have:
for ($i=0; $i < count($result); $i++) {
if($result[$i]["tableB"] == null) {
// At this point:
// - tableB relation is null
// - tableB attribute does not exist
// This is a full dump, so the relations overwrites the attributes.
// tableB will show null
Log:info($result);
// After this assignment executes:
// - tableB relation will be null
// - tableB attribute will be the dummy object
$result[$i]["tableB"] = OtherController::makeDummyTableB();
// This is a full dump, so relations (null) overwrites the attributes (dummy object).
// tableB will show null
Log::info($result); //Print line B
// This is direct access, which accesses attributes (dummy object) before relations (null).
// tableB will show dummy object
Log::info($result[$i]["tableB"]); //Print line C
}
}
If you really want to go about it this way, you should use the setRelation() method:
$result[$i]->setRelation('tableB', OtherController::makeDummyTableB());
That will specifically set tableB on the relations property, which is what you're trying to do. That should get everything working for you.
Having said that, you may be able to tackle this a different way. If you're using Laravel >= 5.3 and your tableB relationship is a HasOne (5.3+) or a BelongsTo (5.4+) relationship, you can use the withDefault() functionality on the relationship so that it will automatically generate a default model when one does not exist in the database. You can read more about this in the documentation here.
So, your relationship definition would look something like:
public function tableB()
{
return $this->hasOne('App\TableB')->withDefault();
}
Now, when no tableB record exists, the relationship will load with a new empty TableB object, instead of null.
If you need something more than just an empty TableB object, you can pass a function to the withDefault() method, and that function will be used to generate the default object.

Eloquent does not return a plain PHP array, when using the get method it will return an instance of Illuminate\Support\Collection and you cannot simply assign an object like that into a Collection. To do so, you have to convert the collection to an array first:
$result = TableA::where('tableA.id', '=', $id)
->with('tableB.tableC')
->get();
// Convert collection to array
$result = $result->toArray();
for ($i=0; $i < count($result); $i++)
{
if($result[$i]["tableB"] == null)
{
Log:info($result); //Print line A
$result[$i]["tableB"] = OtherController::makeDummyTableB(); //Assignment line
Log::info($result); //Print line B
Log::info($result[$i]["tableB"]); //Print line C
}
}
Now you should have your value correctly assigned.

Try
foreach ($result as $whatever)
{
if($whatever->tableB == null)
{
$whatever->tableB = OtherController::makeDummyTableB();
}
}
The problem is that you can't assign a tableB object to a array field of your tableA model...
Your tableB model most likely (I assume, depends how you built it) is related to your tableA model via a "tableB_id" field. So if you use your assignment, you end up with
$tableA["tableB_id"] //id of the related tableB, in your case null
$tableA["tableB"] //your newly assigned model, which has nothing to do with your relationship, because that should work on tableB_id
Just don't use weird array syntax for relations

Related

f3 / Fat Free Framework: why does the SQL-Mapper select function return all columns from a table?

Imagine a MySQL table with two columns, col1 and col2, queried by f3's SQL-Mapper like this:
$rows = $mapper->find();
$rows = $mapper->select('col1');
When using find both columns are queried and returned and one can access them like this:
... = $rows[0]->col1;
... = $rows[0]->col2;
When using select calling $rows[0]->col2 will return null, because col2 was not included in the string argument of the select method, which is fine.
Now when doing a var_dump I noticed that the select method returns all columns! Why is this?
I imagined the purpose of the select method would be to save resources on the database server by only querying for the specified columns. So what is the purpose of the SQL-Mapper: select method if it returns the full set of columns – we have the find method for that, don't we?
The purpose of the Fat-Free SQL mapper is to automatically map table columns to PHP object properties. This is done at instantiation time in DB\SQL\Mapper::__construct.
So when you call $mapper->find() or $mapper->select(), the instantiation has already been performed and table columns have already been mapped to the $mapper object.
This explains the results of your var_dump command.
Now you can adjust the list of columns actually mapped, but that has to be done at instantiation:
// map all columns
$mapper = new DB\SQL\Mapper($db,'table_name');
// only map col1 & col2 columns
$mapper = new DB\SQL\Mapper($db,'table_name','col1,col2');
Concerning the select() method, I'm wondering why this method has been made public. It is used internally by find(), but is not very handy to use on its own, considering that all the specified fields have to match declared columns at instantiation and that computed columns should be both aliased AND declared. See:
$mapper = new DB\SQL\Mapper($db,'table_name','col1,col2');
// ex.1: column not declared
$results = $mapper->select('*');
echo $results[0]->col3; // undefined field col3
// ex.2a: computed column not aliased
$results = $mapper->select('SUM(col1)');
echo $results[0]->{'SUM(col1)'}; // undefined field SUM(col1)
// ex.2b: computed column aliased but not declared
$results = $mapper->select('SUM(col1) AS sum1');
echo $results[0]->sum1; // undefined field sum1
// ex.2c: computed column declared but not aliased
$mapper->sum1 = 'SUM(col1)';
$results = $mapper->select('SUM(col1)');
echo $results[0]->sum1; // empty
// ex.2d: computed column aliased and declared
$mapper->sum1 = 'SUM(col1)';
$results = $mapper->select('SUM(col1) AS sum1');
echo $results[0]->sum1; // OK!
As you can see, the usage of this method is very strict. I wouldn't advise to use it, unless you really know what you're doing. Use find() instead.

Laravel-translatable - "call to a member function save() on string"

(Updated) I use the laravel-translatable package and trying to insert rows with translations. When trying to save, it gives me the error "call to a member function save() on string".
I loop an object with keys and values, like: "food": "Nourriture",
and inside the loop I do a select of the Translations table:
$translationKey = \App\Translation::select('group', 'key')->where('group',
'global')->where('key', $key)->first();
I don't do exactly as the documentaion, which would have been:
$translationKey = \App\Translation::where('key', $key)->first();
The difference is that I select the columns 'group' and 'key', and I do an extra "where" to specify that group = global. Isthere anything wrong there?
Then I try to check if there is an already existing translation. If not, I insert the translation:
if($translationKey->hasTranslation('fr')) {
continue;
}else{
//insert
$translationRow = $translationKey->translateOrNew('fr')->$key = $value;
$translationRow->save();
}
I use translateOrNew instead of translate , because otherwise I get error: "Creating default object from empty value".
It seems I can't do the ->save() method because it's a string, not a model instance which it should be. So I guess there is something wrong with this line?:
$translationKey = \App\Translation::select('group', 'key')->where('group',
'global')->where('key', $key)->first();
But what is the problem?
I had some mistakes - I needed to select the whole row instead of individual columns:
$translationKey = \App\Translation::where('group', 'global')
->where('key', 'about_us')
->first();
And there were mistakes when saving the translation. My translations_translations table has a "value" column, so this worked:
$translationKey->translateOrNew($locale)->value = $value;
$translationKey->save()

Can we add custom values to a CakePHP Table Object?

I have a Cake Object when querying a table:
$invoices = TableRegistry::get('invoices')->find('all', ['conditions' => ['order_number =' => $orderNumber]]);
This works fine. I then want to add other array key/values to this Object, like this one:
$invoicesTmp = array();
$invoicesTmp['customer'] = "name of customer";
But $invoicesTmp is incompatible with $invoices. (one is an array, other is an CakePHP Object)
I have tried this:
compact($invoices, $invoicesTmp);
but that didn't worked.
The find() method of a Table object returns a Cake\ORM\Query object. This object is used to build SQL queries and to execute them. It has some features to define how the results from the query should be returned.
When CakePHP fetches results from the database the records are stored as an array, and CakePHP then converts them to Entity objects. A process called "hydration" of entities. If you disable hydration the records are returned as just an array.
$query = TableRegistry::get('invoices')
->find()
->where(['order_number'=>$orderNumber])
->enableHydration(false);
foreach($query as $record) {
pr($record);
}
The above creates a query object, and you can iterate over the query records because the object itself supports iteration.
The query object implements the Cake\Collection\CollectionInterface interface, which means we can perform a bunch of collection methods on it. The most common method is the toArray().
$invoices = TableRegistry::get('invoices')
->find()
->where(['order_number'=>$orderNumber])
->enableHydration(false)
->toArray();
The $invoices variable is now a valid array object holding the all the records with each record as an array object.
You can now easily use array_merge to assign extra metadata to each record.
$invoices = array_map(function($invoice) {
return array_merge(['customer'=>'name of customer'], $invoice);
}, $invoices);
$this-set(compact('invoices'));
Updated:
Based upon the comments it appears you wish to use two different tables with different column names, but those columns represent the same data.
Field Aliases
You can rename fields in the SQL query to share a common alias.
$table = TableRegistry::get($whichTable ? 'table_a' : 'table_b');
$records = $table->find()
->select([
'id',
'invoice_id',
'name' => ? $whichTable ? 'customer_name' : 'invoice_name'
])->all();
The above selects a different column for name depending upon which table is being used. This allows you to always use $record->name in your view no matter which table.
I don't like this approach, because it makes the source code of the view file appear to reference a property of the entity that doesn't really exist. You might get confused when returning to the code later.
Field Mapping
From a MVC perspective. Only the controller knows what a view needs. So it's easier if you express this knowledge as a mapping.
$map = [
'id'=>'id',
'invoice_id'=>'invoice_id',
'name' => ? $whichTable ? 'customer_name' : 'invoice_name'
];
$table = TableRegistry::get($whichTable ? 'table_a' : 'table_b');
$records = $table->find()
->select(array_values($map))
->all();
$this->set(compact('records','map'));
Later in your view to output the columns you do it like this:
foreach($records as $record) {
echo $record->get($map['name']);
}
It becomes verbose as to what is happening, and why. You can see in the view that the controller provided a mapping between something called name and the actual field. You also know that the $map variable was injected by the controller. You now know where to go to change it.

Codeigniter how to map a class sent to a model

I have a SectorModel with this function:
public function update(Sector $sector) {
$this->db->where('sector_id', $sector->getScetor_id());
return $this->db->update(_SECTOR_, $sector);
}
There are times that I’ll change only the name of the Sector object:
$Sector = new Sector();
$Sector->setSector_name = 'test';
$this->SectorModel->update($Sector);
The generated select looks like:
UPDATE realestate_sector SET sector_name = 'teste', sector_description = NULL
It will update but will set all other properties to NULL because it was not set on my object.
Right now, I have to fill the whole object before sending it.
Is there a way to map the Sector class and update only what was sent on the object?
Thanks in advance for any help.
Sorry for any typos, my English is not good =)
Just loop through all your object's properites and then if any is NULL just drop it with unset.
Here is your model's method edited to achieve that:
public function update(Sector $sector)
{
foreach($sector as $k=>$v)
{
if($v === NULL)
unset($sector->$k)
}
$this->db->where('sector_id', $sector->getScetor_id());
return $this->db->update(_SECTOR_, $sector);
}
Here you can find some info about iterating objects in PHP
The easiest to do this would be to rather use a array - docs here http://codeigniter.com/user_guide/database/active_record.html#update - you just create a array of all the columns with their values that you want to update and perform a $this->db->update('mytable', array('name' => 'test'), array('id' => $id)); call. This will only update the columns you specified in the First array. With the second array acting as your WHERE expression.
The only reason I can think of as to why your other values are being set to NULL is because in your example you create a new instance of the class and the other values must either have been set to nothing or are set to NULL. It would (If this is the case) be better to get a record from the table and then change and values on the populated record and pass that to the function to update.
Hope that helps.

Symfony app - how to add calculated fields to Propel objects?

What is the best way of working with calculated fields of Propel objects?
Say I have an object "Customer" that has a corresponding table "customers" and each column corresponds to an attribute of my object. What I would like to do is: add a calculated attribute "Number of completed orders" to my object when using it on View A but not on Views B and C.
The calculated attribute is a COUNT() of "Order" objects linked to my "Customer" object via ID.
What I can do now is to first select all Customer objects, then iteratively count Orders for all of them, but I'd think doing it in a single query would improve performance. But I cannot properly "hydrate" my Propel object since it does not contain the definition of the calculated field(s).
How would you approach it?
There are several choices. First, is to create a view in your DB that will do the counts for you, similar to my answer here. I do this for a current Symfony project I work on where the read-only attributes for a given table are actually much, much wider than the table itself. This is my recommendation since grouping columns (max(), count(), etc) are read-only anyway.
The other options are to actually build this functionality into your model. You absolutely CAN do this hydration yourself, but it's a bit complicated. Here's the rough steps
Add the columns to your Table class as protected data members.
Write the appropriate getters and setters for these columns
Override the hydrate method and within, populate your new columns with the data from other queries. Make sure to call parent::hydrate() as the first line
However, this isn't much better than what you're talking about already. You'll still need N + 1 queries to retrieve a single record set. However, you can get creative in step #3 so that N is the number of calculated columns, not the number of rows returned.
Another option is to create a custom selection method on your TablePeer class.
Do steps 1 and 2 from above.
Write custom SQL that you will query manually via the Propel::getConnection() process.
Create the dataset manually by iterating over the result set, and handle custom hydration at this point as to not break hydration when use by the doSelect processes.
Here's an example of this approach
<?php
class TablePeer extends BaseTablePeer
{
public static function selectWithCalculatedColumns()
{
// Do our custom selection, still using propel's column data constants
$sql = "
SELECT " . implode( ', ', self::getFieldNames( BasePeer::TYPE_COLNAME ) ) . "
, count(" . JoinedTablePeer::ID . ") AS calc_col
FROM " . self::TABLE_NAME . "
LEFT JOIN " . JoinedTablePeer::TABLE_NAME . "
ON " . JoinedTablePeer::ID . " = " . self::FKEY_COLUMN
;
// Get the result set
$conn = Propel::getConnection();
$stmt = $conn->prepareStatement( $sql );
$rs = $stmt->executeQuery( array(), ResultSet::FETCHMODE_NUM );
// Create an empty rowset
$rowset = array();
// Iterate over the result set
while ( $rs->next() )
{
// Create each row individually
$row = new Table();
$startcol = $row->hydrate( $rs );
// Use our custom setter to populate the new column
$row->setCalcCol( $row->get( $startcol ) );
$rowset[] = $row;
}
return $rowset;
}
}
There may be other solutions to your problem, but they are beyond my knowledge. Best of luck!
I am doing this in a project now by overriding hydrate() and Peer::addSelectColumns() for accessing postgis fields:
// in peer
public static function locationAsEWKTColumnIndex()
{
return GeographyPeer::NUM_COLUMNS - GeographyPeer::NUM_LAZY_LOAD_COLUMNS;
}
public static function polygonAsEWKTColumnIndex()
{
return GeographyPeer::NUM_COLUMNS - GeographyPeer::NUM_LAZY_LOAD_COLUMNS + 1;
}
public static function addSelectColumns(Criteria $criteria)
{
parent::addSelectColumns($criteria);
$criteria->addAsColumn("locationAsEWKT", "AsEWKT(" . GeographyPeer::LOCATION . ")");
$criteria->addAsColumn("polygonAsEWKT", "AsEWKT(" . GeographyPeer::POLYGON . ")");
}
// in object
public function hydrate($row, $startcol = 0, $rehydrate = false)
{
$r = parent::hydrate($row, $startcol, $rehydrate);
if ($row[GeographyPeer::locationAsEWKTColumnIndex()]) // load GIS info from DB IFF the location field is populated. NOTE: These fields are either both NULL or both NOT NULL, so this IF is OK
{
$this->location_ = GeoPoint::PointFromEWKT($row[GeographyPeer::locationAsEWKTColumnIndex()]); // load gis data from extra select columns See GeographyPeer::addSelectColumns().
$this->polygon_ = GeoMultiPolygon::MultiPolygonFromEWKT($row[GeographyPeer::polygonAsEWKTColumnIndex()]); // load gis data from extra select columns See GeographyPeer::addSelectColumns().
}
return $r;
}
There's something goofy with AddAsColumn() but I can't remember at the moment, but this does work. You can read more about the AddAsColumn() issues.
Here's what I did to solve this without any additional queries:
Problem
Needed to add a custom COUNT field to a typical result set used with the Symfony Pager. However, as we know, Propel doesn't support this out the box. So the easy solution is to just do something like this in the template:
foreach ($pager->getResults() as $project):
echo $project->getName() . ' and ' . $project->getNumMembers()
endforeach;
Where getNumMembers() runs a separate COUNT query for each $project object. Of course, we know this is grossly inefficient because you can do the COUNT on the fly by adding it as a column to the original SELECT query, saving a query for each result displayed.
I had several different pages displaying this result set, all using different Criteria. So writing my own SQL query string with PDO directly would be way too much hassle as I'd have to get into the Criteria object and mess around trying to form a query string based on whatever was in it!
So, what I did in the end avoids all that, letting Propel's native code work with the Criteria and create the SQL as usual.
1 - First create the [get/set]NumMembers() equivalent accessor/mutator methods in the model object that gets returning by the doSelect(). Remember, the accessor doesn't do the COUNT query anymore, it just holds its value.
2 - Go into the peer class and override the parent doSelect() method and copy all code from it exactly as it is
3 - Remove this bit because getMixerPreSelectHook is a private method of the base peer (or copy it into your peer if you need it):
// symfony_behaviors behavior
foreach (sfMixer::getCallables(self::getMixerPreSelectHook(__FUNCTION__)) as $sf_hook)
{
call_user_func($sf_hook, 'BaseTsProjectPeer', $criteria, $con);
}
4 - Now add your custom COUNT field to the doSelect method in your peer class:
// copied into ProjectPeer - overrides BaseProjectPeer::doSelectJoinUser()
public static function doSelectJoinUser(Criteria $criteria, ...)
{
// copied from parent method, along with everything else
ProjectPeer::addSelectColumns($criteria);
$startcol = (ProjectPeer::NUM_COLUMNS - ProjectPeer::NUM_LAZY_LOAD_COLUMNS);
UserPeer::addSelectColumns($criteria);
// now add our custom COUNT column after all other columns have been added
// so as to not screw up Propel's position matching system when hydrating
// the Project and User objects.
$criteria->addSelectColumn('COUNT(' . ProjectMemberPeer::ID . ')');
// now add the GROUP BY clause to count members by project
$criteria->addGroupByColumn(self::ID);
// more parent code
...
// until we get to this bit inside the hydrating loop:
$obj1 = new $cls();
$obj1->hydrate($row);
// AND...hydrate our custom COUNT property (the last column)
$obj1->setNumMembers($row[count($row) - 1]);
// more code copied from parent
...
return $results;
}
That's it. Now you have the additional COUNT field added to your object without doing a separate query to get it as you spit out the results. The only drawback to this solution is that you've had to copy all the parent code because you need to add bits right in the middle of it. But in my situation, this seemed like a small compromise to save all those queries and not write my own SQL query string.
Add an attribute "orders_count" to a Customer, and then write something like this:
class Order {
...
public function save($conn = null) {
$customer = $this->getCustomer();
$customer->setOrdersCount($customer->getOrdersCount() + 1);
$custoner->save();
parent::save();
}
...
}
You can use not only the "save" method, but the idea stays the same. Unfortunately, Propel doesn't support any "magic" for such fields.
Propel actually builds an automatic function based on the name of the linked field. Let's say you have a schema like this:
customer:
id:
name:
...
order:
id:
customer_id: # links to customer table automagically
completed: { type: boolean, default false }
...
When you build your model, your Customer object will have a method getOrders() that will retrieve all orders associated with that customer. You can then simply use count($customer->getOrders()) to get the number of orders for that customer.
The downside is this will also fetch and hydrate those Order objects. On most RDBMS, the only performance difference between pulling the records or using COUNT() is the bandwidth used to return the results set. If that bandwidth would be significant for your application, you might want to create a method in the Customer object that builds the COUNT() query manually using Creole:
// in lib/model/Customer.php
class Customer extends BaseCustomer
{
public function CountOrders()
{
$connection = Propel::getConnection();
$query = "SELECT COUNT(*) AS count FROM %s WHERE customer_id='%s'";
$statement = $connection->prepareStatement(sprintf($query, CustomerPeer::TABLE_NAME, $this->getId());
$resultset = $statement->executeQuery();
$resultset->next();
return $resultset->getInt('count');
}
...
}

Categories