Counting and categorising instances of certain fields - cakephp

I'm currently writing a survey style app that has some models:
Assessments
Recommendations
An assessment has many recommendations and is joined by the assessments_recommendations table, and I store data in the join table which are effectively answers.
One such answer is user impact. This field is an integer value between 0 and 10 and is different for each assessment->recommendation.
What I want to be able to do is send a count and categorisation of that field into a view so that I can chart it using chart.js. For example, I would like:
0-3 = Low
4-7 = Moderate
8-10 = High
As far as I can tell, I need to pass something along the lines of:
$counts [4, 6, 8]
$legend ['Low', 'Moderate', 'High']
So in the above example, 4 assessment->recommendations are classed as low, 6 are moderate, 8 are high.
Does anyone have any advice on how to do this in my controller? I've been reading up on collections but I'm not sure that is the correct way of doing this. I had issues as my data is stored in the _joinData part of the array and I couldn't figure out how to access it.
Any advice would be much appreciated.
Thanks!
Edit 1
This is the controller code I have been playing around with to get an initial collection working. The col variable passes through nicely and I can see this in the view. I get 'unknown getter' error on the join data line though. It isn't entirely clear to me from the docs what variables are what in the example in the book, so I've been very much trying every combo I can think of to try and get it to work before even trying to move on with the complete scenario.
public function view($id = null)
{
$this->loadModel ('Assessments');
$this->loadModel ('Recommendations');
$query = $this->Assessments->find('all', [
'conditions' => ['Assessments.id =' => $id],
'contain' => ['Recommendations']
]);
$assessment = $query->first();
$collection = new Collection($assessment->ToArray());
$countHigh = $collection->countBy(function ($assessment) {
return $assessment->_joinData->_user_impact > 7 ? 'High' : 'Error';
});
$this->set('assessments', $assessment);
$this->set('col', $collection);
$this->set('user_impact_high', $countHigh);
}
Edit 2
So I'm testing this some more. Trying to simplify it. Even using the groupBy function in it's simple form is failing. The code below generates the following error.
public function view($id = null)
{
$this->loadModel ('Assessments');
$this->loadModel ('Recommendations');
$query = $this->Assessments->find('all', [
'conditions' => ['Assessments.id =' => $id],
'contain' => ['Recommendations']
]);
$assessment = $query->first();
$collection = new Collection($assessment->ToArray());
$test = $collection->groupBy('client_id');
$this->set('assessments', $assessment);
$this->set('col', $collection);
$this->set('test', $test);
}
Error:
Cannot use object of type Cake\I18n\FrozenTime as array

Related

CakePHP 3 - unable to generate a query with WHERE...OR conditions

CakePHP 3.7
I'm trying to generate a query which uses a WHERE...OR pattern. The equivalent in MySQL - which executes and gives the results I want is:
SELECT * FROM groups Groups WHERE (regulation_id = 1 AND label like '%labelling%') OR (id IN(89,1,8,232,228,276,268,294));
I've read the Advanced Conditions (https://book.cakephp.org/3.0/en/orm/query-builder.html#advanced-conditions) part of the documentation but can't generate that query.
Assume the Table class is Groups I have this:
$Groups = TableRegistry::getTableLocator()->get('Groups');
$groups_data = $Groups->find('all')->where(['regulation_id' => 1);
$groups_data = $groups_data->where(['label LIKE' => '%labelling%']);
This produces the first segment of the WHERE statement, i.e.
SELECT * FROM groups Groups WHERE (regulation_id = 1 AND label like '%labelling%')
However I can't see how to attach the OR condition, especially since orWhere() is deprecated.
So I've tried this - which is even given as an example in the docs:
$in_array = [89,1,8,232,228,276,268,294]; // ID's for IN condition
$groups_data = $groups_data->where(['OR' => ['id IN' => $in_array]]);
But this just appends an AND to the inside of my existing SQL:
SELECT * FROM groups Groups WHERE (regulation_id = 1 AND label like '%labelling%' AND id IN(89,1,8,232,228,276,268,294);
Which does not yield the correct results as the syntax isn't what's required to run this query.
How do you "move out" of the WHERE and append an OR condition like in the vanilla query?
I made several attempts using QueryExpression as per the docs, but all of these produced PHP Fatal Errors saying something to do with the Table class - I doubt this was on the right lines anyway.
"moving out" is a little tricky, you have to understand that internally the conditions are pushed into a \Cake\Database\Expression\QueryExpression object which by default uses AND to concatenate the statements, so whatever you push on to that, will be added using AND.
When you create OR statements, being it implicitly with the shown nested array syntax, or explicitly by using the expression builder, this creates a separate, self-contained expression, where its parts are being concatenated using OR, it will compile itself (and since there's only one condition, you don't see any OR's), and the result will be used in the parent expression, which in your case is the main/base expression object for the queries where clause.
Either pass the whole thing at once (being it via array syntax or expressions), eg:
$groups_data->where([
'OR' => [
'AND' => [
'regulation_id' => 1,
'label LIKE' => '%labelling%'
],
'id IN' => $in_array
]
]);
and of course you could build that array dynamically if required, or, if you for some reason need to use separate calls to where(), you could for example overwrite the conditions (third parameter of where()), and include the current ones where you need them:
$groups_data->where(
[
'OR' => [
$groups_data->clause('where'),
'id IN' => $in_array
]
],
[],
true
);
I know this issue is old but maybe someone is looking. Here is my solution:
protected $_hardValues= array(
'company_id' => $company_from_session;
);
function beforeFind($event=null, $query = null, $options = null, $primary = true){
$conds = [];
$columns = $this->getSchema()->columns();
foreach( $this->_hardValues as $field => $value){
if( !is_null($value) && in_array($field, $columns) ){
$conds[$this->_alias . '.' . $field] = $value;
}
}
if( empty( $conds)) return true;
$where = $query->clause('where'); //QueryExpression object;
if( empty( $where)){
$query->where($conds);
}else{
$where->add($conds);
}
}
As of CakePHP 4.x, the documented way of doing this is:
$query = $articles->find()
->where([
'author_id' => 3,
'OR' => [['view_count' => 2], ['view_count' => 3]],
]);
See documentation

Cakephp 3 : How to get max amout row from a table

I have table call users , like
id name amount created
1 a 100 6-16-2016
2 b 200 5-16-2016
I need max amount full row, I have tried below code but getting syntax error.
$user = $this->Users->find('all',[
'fields' => array('MAX(Users.amount) AS amount'),
]);
simplest way
$user = $this->Users->find('all',[
'fields' => array('amount' => 'MAX(Users.id)'),
]);
using select instead of an options array
$user = $this->Users->find()
->select(['amount' => 'MAX(Users.id)']);
making use of cake SQL functions
$query = $this->Users->find();
$user = $query
->select(['amount' => $query->func()->max('Users.id')]);
the above three all give the same results
if you want to have a single record you have to call ->first() on the query object:
$user = $user->first();
$amount = $user->amount;
Simplest method using CakePHP 3:
$this->Model->find('all')->select('amount')->hydrate(false)->max('amount')
Will result in an array containing the maximum amount in that table column.
Is is better to disable hydration before getting the results, to get the results as an array, instead of entity. Below is a complete example of how to get the results in an array, with distinct steps:
//create query
$query = $this->Users->find('all',[
'fields' => array('amount' => 'MAX(Users.id)'),
]);
$query->enableHydration(false); // Results as arrays instead of entities
$results = $query->all(); // Get ResultSet that contains array data.
$maxAmount = $results->toList(); // Once we have a result set we can get all the rows

How to specify a condition to retrieve data by radius (lat, lon)?

In cakephp 2, I was able to use a virtualField for this, but seems impossible in 3. I have been struggling with this for two days without luck from the internet or the cakephp manual. I get no errors, but I do get a blank return.
My code in my controller looks like this:
if (isset($this->request->data['location']) && (isset($this->request->data['radius']))){
$radius = $this->request->data['radius'];
$location = $this->request->data['location'];
$address = $location; // Google HQ
$HttpSocket = new Client();
$geocode = $HttpSocket->get('http://maps.google.com/maps/api/geocode/json?address='.$address.'&sensor=false');
$geocode = $geocode->json;
if ($geocode['status'] == "OK"){
$lat = $geocode['results'][0]['geometry']['location']['lat'];
$lon = $geocode['results'][0]['geometry']['location']['lng'];
$R = 6371; // earth's mean radius, km
// first-cut bounding box (in degrees)
$maxLat = $lat + rad2deg($radius/$R);
$minLat = $lat - rad2deg($radius/$R);
// compensate for degrees longitude getting smaller with increasing latitude
$maxLon = $lon + rad2deg($radius/$R/cos(deg2rad($lat)));
$minLon = $lon - rad2deg($radius/$R/cos(deg2rad($lat)));
$conditions[] = ['Venues.lat' => "'BETWEEN '".$minLat."' AND '".$maxLat."'"];
$conditions[] = ['Venues.lon' => "'BETWEEN :'".$minLon."' AND :'".$maxLon."'"];
}
$this->paginate =[
'limit' => 10,
'order' => ['Quads.date' => 'asc'],
'conditions' => $conditions,
'contain' => [
'Performers' => ['Genres'],
'Users' => ['Profiles'],
'Venues' => ['fields' => [
'name',
'id',
'verified',
'address1',
'city',
'zip_code'], 'States'=>['Countries']],
'Categories',
'Likes' => ['Users' => ['Profiles']]]];
$quads = $this->paginate();
Impossible is (nearly) nothing. The old virtual fields concept is gone, right, the new ORM is flexible enough so that this isn't necessary anymore.
Your problem is that you are defining the conditions the wrong way, what you are doing there by specifying key => value sets, is creating ordinary operator conditions, where the value is going to be escaped/casted according to the column type. In case you really don't receive any errors, I'd assume that the lat/lan columns are of a numeric type, so your BETWEEN ... strings do end up as numbers, and the conditions will look something like
Venus.lat = 0 AND Venus.lon = 0
Also note that you are creating a nested array, ie
[
['keyA' => 'value'],
['keyB' => 'value']
]
and while this works, you may run into further problems in case you're not aware of it, so you'd better stick with
[
'keyA' => 'value',
'keyB' => 'value'
]
unless there's actually a technical reason to use nested conditions.
tl;dr use expressions
That being said, you can use expressions to build the proper conditions, like
$conditions[] = $this->Quads->Venues
->query()->newExpr()->between('Venues.lat', $minLat, $maxLat);
$conditions[] = $this->Quads->Venues
->query()->newExpr()->between('Venues.lon', $minLon, $maxLon);
This will safely create proper conditions like
Venus.lat BETWEEN a AND b AND Venus.lon BETWEEN x AND Y
Note that it is advised to create the expressions via the table that holds the columns (VenuesTable in this case), as you'd otherwise have to manually specify the column type (see the fourth argument of QueryExpression::between()) in order for the correct casting/escaping to be applied!
See also
Cookbook > Database Access & ORM > Query Builder > Advanced Conditions
Cookbook > Database Access & ORM > Query Builder > Using SQL Functions
API > \Cake\Database\QueryExpression::between()

cakephp combine find() queries

I am still new to Cakephp I have 3 find queries to the same model and I have tried to combine them to make one. Finding it hard with the cake way of accessing the data. Especial that they have different find condition. Maybe it cant be done.
Cakephp version 2.3.5
// Count Total Members
$totalMemebers = $this->Member->find('count');
$this->set('totalMemebers', $totalMemebers);
// SUM total points gained for the last 7 days (positive values)
$this->Member->Point->virtualFields['gainedTotal'] = 'SUM(Point.points)';
$gainedTotal = $this->Member->Point->find('all', array(
'recursive'=> -1,
'fields' => array('gainedTotal'),
'conditions'=>array(
'Point.points >'=>0,
'Point.date >' => date('Y-m-d', strtotime("-1 weeks")))
)
);
$this->set('gainedTotal', $gainedTotal);
// SUM total points redeemed for the last 7 days (negative values)
$this->Member->Point->virtualFields['redeemedTotal'] = 'SUM(Point.points)';
$redeemedTotal = $this->Member->Point->find('all', array(
'recursive'=> -1,
'fields' => array('redeemedTotal'),
'conditions'=>array(
'Point.points <'=>0),
'Point.date >' => date('Y-m-d', strtotime("-1 weeks"))
)
);
$this->set('redeemedTotal', $redeemedTotal);
Because all queries use different conditions, it will not be possible to combine them to a single query. This is not a limitation of CakePHP, using regular SQL will not allow you to do so either, unless you're going to use sub queries or some calculated fields (SELECT CASE WHEN points > 0 AND date > .... THEN points ELSE 0 END CASE AS gainedPoint) but this will offer no benefits (performance wise)
Optimizations
To optimise (and clean up) your code, there are some improvements possible;
First of all, to get the value of a single field, you may use Model::field(). This will return just the plain value of a field, not an array containing the value.
For example:
$this->Member->Point->virtualFields['gainedTotal'] = 'SUM(Point.points)';
$gainedTotal = $this->Member->Point->field('gainedTotal', array(
'Point.points >'=>0,
'Point.date >' => date('Y-m-d', strtotime("-1 weeks"))
));
$this->set('gainedTotal', $gainedTotal);
Move data-related code to your model
This is something you should do in general. Move those queries to your Models. This will make your code cleaner and better to maintain. Also, your queries will be easier to re-use and finally, you will be able to Unit-test the queries.
class Point extends AppModel
{
// general stuff/properties here
/**
* Returns the SUM total points gained for
* the last 7 days (positive values)
*
* #return int Gained total for all members
*/
public function gainedTotal()
{
this->virtualFields['gainedTotal'] = 'SUM(points)';
$gainedTotal = $this->field('gainedTotal', array(
'points >'=>0,
'date >' => date('Y-m-d', strtotime("-1 weeks"))
));
// remove the virtualField to prevent it being used
// in subsequent queries
unset($this->virtualFields['gainedTotal']);
return (int)$gainedTotal;
}
}
And, inside your Controller, simply do this:
$this->set('gainedTotal', $this->Member->Point->gainedTotal());
This will clearly reduce the amount of code inside your Controller ('lean controllers, fat models'), which is always good practice.
Add relevant indexes to your database
If performance is important, you can massively improve the performance of these queries by adding the right indexes to your database, especially when calculating totals for a large number of records.
Describing this in detail is beyond the scope of this question, but here are some pointers; How MySQL uses indexes and CREATE INDEX SYNTAX

Should I use more find in this case?

i have two controllers
Sections_controller.php
Articles_controller.php
Section model hasmany Article...
i want to fetch articles In the form of blocks like all news sites..every block have section name with links to the articles within this section..so i use this code......
The First block
$block1=$this->Article->find('all',
array(
'limit' => 4, // just fetch 4 articles
'order' => array('Article.created'=>'DESC'),
'conditions' => array('Section_id' => 87)
)
);
// set the section for the view
$this->set(compact('block1'));
The second block
$block2=$this->Article->find('all',
array(
'limit' => 4, // just fetch 4 articles
'order' => array('Article.created'=>'DESC'),
'conditions' => array('Section_id' => 88)
)
);
// set the section for the view
$this->set(compact('block2'));
and etc....
anyone have the best method in this task without Repetition find code..
notice..i cant pass $id in the function because articles must display when request site index example( www.newssite.com)
Any find(s) should be done in the Model, not the controller - this follows the MVC structure as well as the "fat models, skinny controllers" mantra, which helps keep with the MVC idea.
This is not only the way it "should" be done, but it will also allow you to have the code in just one place:
//in the Article model
function getArticlesBySection($id) {
$articles = $this->find('all', array(
'limit' => 4,
'order' => array('Article.created'=>'DESC'),
'conditions' => array('Section_id' => $id)
));
return $articles;
}
//in the Articles controller
$block1 = $this->Article->getArticlesBySection('87');
$block2 = $this->Article->getArticlesBySection('88');
$this->set(compact('block1', 'block2'));
The above should work just fine for what you want to do, but there is always a lot you can do to improve upon it - like setting it up to be a lot more flexible by accepting an array of options:
//in the Article model
function getArticles($id, $opts = null) {
$params = array();
//limit
$params['limit'] = 100; //catchall if no limit is passed
if(!empty($opts['limit'])) $params['limit'] = $opts['limit'];
//order
$params['order'] = array('Article.created'=>'DESC');
if(!empty($opts['order'])) $params['order'] = $opts['order'];
//conditions
$params['conditions'] = array();
if(!empty($opts['sections'])) array_push($params['conditions'], array('Section_id'=>$opts['sections']));
$articles = $this->find('all', $params);
return $articles;
}
//in the Articles controller
$opts = array('limit'=>4, 'sections'=>array('87'));
$block1 = $this->Article->getArticles($opts);
$opts['sections'] = array('88');
$block2 = $this->Article->getArticles($opts);
I'm sure there are things that can be done to make this more lean...etc, but it's how I like to write it for ease of use and readability, and at least gives you a start on how to think of model methods, and the ability to use and reuse them for different purposes.
You can accomplish this with a straight mysql query, but I'm not sure how you would fit it into a cake model->find function. You can do something like this:
$articles = $this->Article->query("SELECT * FROM articles a WHERE (SELECT COUNT(*) FROM articles b WHERE a.Section_id = b.Section_id AND a.created < b.created) < 4 ORDER BY a.Section_id, a.created DESC");
/* if you look at the results, you should have the 4 latest articles per section.
Now you can loop through and set up an array to filter them by section. Modify to fit your needs */
foreach($articles as $article) {
$results[$article['Article']['Section_id']][] = $article;
}
$this->set('results',$results);

Resources