I've got to sort a list of Reservations (they're coupled to an event by defining a belongsTo association) by the last name of the person who registered the ticket.
I do this in cakePHP:
$reservations = Set::sort($eventinfo['Reservation'],'{n}.last_name','asc');
This works, but some users input their data in all lowercase, which makes the sorting wrong:
Alfa, Ziggy, aardvark, zorro
Where it should be:
aardvark, Alfa, Ziggy, zorro
How can I fix this? I could loop over the array and make every string start with an uppercase letter using ucword(), but that looks a bit ugly. Isn't there an easy way to alter the sort algorithm so it ignores case?
I would be normalising the surnames before storing them - either all lowercase, all UPPERCASE or all Capitalised.
Is there a reason you're not performing the sorting in SQL?
I assume there's a find operation shortly before you call Set::sort, which I imagine looks something like:
$reservations = $this->Event->find('first',array(
'conditions' => array('Event.id' => $my_event_id),
'contain' => array('Reservation')
));
You can instruct Cake's ORM to sort the contained Reservations with the same syntax as a standard find operation, like so:
$reservations = $this->Event->find('first',array(
'conditions' => array('Event.id' => $my_event_id),
'contain' => array(
'Reservation' => array('order'=>'Reservation.last_name')
)
));
It looks like in the code Set::sort is using array_multisort under the hood http://php.net/manual/en/function.array-multisort.php so it is not going to give you case insensitive sorting. You could look at the code for Set::sort and make your own subclass of Set that uses something like natcasesort http://www.php.net/manual/en/function.natcasesort.php or use mysql to do the sorting first, which by default in mysql would be case insensitive sorting
Related
I'm trying to create a query that returns the sum of a column using a case (it has logged time and the format in either minutes or hours, if it's in hours, multiply by 60 to convert to minutes). I'm very close, however the query is not populating the ELSE part of the CASE.
The finder method is:
public function findWithTotalTime(Query $query, array $options)
{
$conversionCase = $query->newExpr()
->addCase(
$query->newExpr()->add(['Times.time' => 'hours']),
['Times.time*60', 'Times.time'],
['integer', 'integer']
);
return $query->join([
'table' => 'times',
'alias' => 'Times',
'type' => 'LEFT',
'conditions' => 'Times.category_id = Categories.id'
])->select([
'Categories.name',
'total' => $query->func()->sum($conversionCase)
])->group('Categories.name');
}
The resulting query is:
SELECT Categories.name AS `Categories__name`, (SUM((CASE WHEN
Times.time = :c0 THEN :c1 END))) AS `total` FROM categories Categories
LEFT JOIN times Times ON Times.category_id = Categories.id GROUP BY
Categories.name
It's missing the ELSE statement before the CASE end, which according to the API docs:
...the last $value is used as the ELSE value...
https://api.cakephp.org/3.3/class-Cake.Database.Expression.QueryExpression.html
I know there might be a better way to do this, but at this point I'd like to at least know how to do CASE statements properly using the built in QueryBuilder.
Both arguments must be arrays
Looks like there are some documenation issues in the Cookbook, and the API could maybe be a little more clear on that subject too. Both, the $conditions argument as well as the $values argument must be arrays in order for this to work.
Enforcing types ends up with casting values
Also you're passing the SQL expression wrong, including the wrong types, defining the types as integer will cause the data passed in $values to be casted to these types, which means that you will be left with 0s.
The syntax that you're using is useful when dealing with user input, which needs to be passed safely. In your case however you want to pass hardcoded identifiers, so what you have to do is to use the key => value syntax to pass the values as literals or identifiers. That would look something like:
'Times.time' => 'identifier'
However, unfortunately there seems to be a bug (or at least an undocumented limitation) which causes the else part to not recognize this syntax properly, so for now you'd have to use the manual way, that is by passing proper expression objects, which btw, you may should have done for the Times.time*60 anyways, as it would otherwise break in case automatic identifier quoting is being applied/required.
tl;dr, Example time
Here's a complete example with all forementioned techniques:
use Cake\Database\Expression\IdentifierExpression;
// ...
$conversionCase = $query
->newExpr()
->addCase(
[
$query->newExpr()->add(['Times.time' => 'hours'])
],
[
$query
->newExpr(new IdentifierExpression('Times.time'))
->add('60')
->tieWith('*'), // setConjunction() as of 3.4.0
new IdentifierExpression('Times.time')
],
);
If you were for sure that you'd never ever make use of automatic identifier quoting, then you could just pass the multiplication fragment as:
'Times.time * 60' => 'literal'
or:
$query->newExpr('Times.time * 60')
See also
Cookbook > Database Access & ORM > Query Builder > Case statements
Cookbook > Database Access & ORM > Query Builder > Using SQL Functions
API > \Cake\Database\Expression\QueryExpression::add()
API > \Cake\Database\Expression\QueryExpression::tieWith()
On performing the below operation,
$this->mongo_db->order_by(array('first_name' => 'ASC'))->get('users');
the records starting with A-Z come before the records starting with a-z.
I want them to be sorted alphabetically irrespective of their cases.
e.g. AaBbcCDd....Zz
This is because MongoDB, for one, does not have case insensitive indexes ( https://jira.mongodb.org/browse/SERVER-90 ) and for two (the reason why it doesn't) is because it does not have collations yet ( https://jira.mongodb.org/browse/SERVER-1920 ).
Derick Rethans, one of the PHP driver maintainers, recently wrote a blog post with a possible solution to natural language sorting, http://derickrethans.nl/mongodb-collation.html but it still requires a separate field
The problem is because the ascii values of A-Z < a-z.
You need to use the $toLower function in the aggregate pipeline as below,
Project an extra field for each record to hold the first_name in
lowercase.
Sort based on the projected field in ascending order.
Project the required fields.
The Code as acceptable in the mongo shell:
db.collection.aggregate([
{$project:{"users":1,"first_name":{$toLower:"$first_name"}}},
{$sort:{"first_name":1}},
{$project:{"users":1,"_id":0}}
],{allowDiskUse:true})
If your business permits to store the first_name field value in either lowercase or uppercase, you can avoid this problem. And more importantly you can index the first_name field and query, in the case, that the first_field is stored.
As requested, please find below the code in PHP:
$proj1 = array("users" => 1,'first_name' => array ('$toLower' => '$first_name'))
$sort = array("first_name" => 1)
$proj2 = array("users" => 1,"_id" => 0)
$options = array("allowDiskUse" => true)
$collection -> aggregate(array($proj1,$sort,$proj2),$options)
is it good or bad practice in CakePHP to have conditions set in the contain of a find query like:
$data = $this->SomeModel->find('all', array(
'contain' => array(
'AnotherModel' => array(
'conditions' => array(
// some conditions
)
)
)
));
In which cases will putting conditions inside a contain be useful, and when should I use it or not. Sorry, this is still confusing to me.
Thank you
I'm not sure whether this is a good idea... First of all, I'm going to assume that this specific case requires you to specify conditions that unique to this case, i.e. not general enough for you to put into your SomeModel model relationship criteria to AnotherModel.
My suggestions would be that you should put those conditions in your overall find conditions, as contain specifies which linked models to return (controlling the joins under the hood). Like in SQL, you can join another table and specify which records to match in your WHERE clause.
From the manual, you can specify conditions in your contain, but they won't affect the results that don't join your model like the overall conditions will.
I'd do this:
$data = $this->SomeModel->find('all', array(
'contain' => array('AnotherModel'),
'conditions' => array(
// some conditions relating to AnotherModel
)
));
I have array like this
$conditions = array("Post.title" => "This is a post");
And using $conditions array in this method.
$this->Post->find('first', array('conditions' => $conditions));
I want convert the $conditions array to normal sql query.
I want use
$this->Post->query($converted_query);
instead of
$this->Post->find('first', array('conditions' => $conditions));
$null=null;
echo $this->getDataSource()->generateAssociationQuery($this, NULL, NULL, NULL, NULL, $query_array, false,$null);
To do what you want you could do two things:
1) Combine your $conditions arrays and let CakePHP build your new query so you can simply use $this->Model->find() again.
2) Use this. It's an expansion for the mysql datasource that adds the option to do $this->Model->find('sql', array('conditions' => $conditions)) which will return the SQL-query. This option might cause trouble, because for some find calls (especially when you're fetching associated models) CakePHP uses multiple queryies to fetch the associated models (especially in case of hasMany-associations).
If at all possible, option 1 will probably cause the least trouble. Another problem with going with 2 is that if you're trying to combine two queries with conflicting conditions (like 'name = Hansel' in query 1 and 'name = Gretel' in query 2) you will just find nothing unless you plan on writing extra code to parse the resulting queries and look for conflicts..
Going with 1 will probably be a lot simpler and will probably avoid lots of problems.
I want to search for a partial first and last name match - for example in sql
f_name LIKE J% OR l_name LIKE S%
would match John Smith or John Henry or Harry Smith .
I am assuming I may need to use the "$or" operator,
I have this so far that I believe is doing the LIKE % part properly, but I believe it is doing an "AND" search (meaning it searches for f_name LIKE J% AND l_name LIKE S% so it would only match John Smith):
$name1="J";
$name2="S";
$cursor = $this->clientCollection->find(array('f_name' => new MongoRegex('/^'.$name1.'/i'), 'l_name' => new MongoRegex('/^'.$name2.'/i') ));
Note: This will match containing as in %J%
MongoRegex('/'.$name1.'/i')
This will match starts with (notice the added ^) as in J%
MongoRegex('/^'.$name1.'/i')
$or takes an array of clauses, so you basically just need to wrap another array around your current query:
array('$or' => array(
array('f_name' => new MongoRegex('/'.$name1.'/i')),
array('l_name' => new MongoRegex('/'.$name2.'/i'))
));
Edit: the previous example missed an inner set of array() calls.
The original, wrong, example that I posted looked like this:
array('$or' => array(
'f_name' => new MongoRegex('/'.$name1.'/i'),
'l_name' => new MongoRegex('/'.$name2.'/i')
));
This is a valid query, but not a useful one. Essentially, the f_name and l_name query parts are still ANDed together, so the $or part is useless (it's only getting passed one query, so it's the same as just running that query by itself).
As for the alternative you mentioned in your comment, that one doesn't work because the outermost array in a query has to be an associative array. The confusion arises because Mongo's query syntax is JSON-like and uses a mixture of objects and arrays, but both of those structures are represented in PHP by arrays. The PHP Mongo driver basically converts PHP associative arrays to JSON objects ({ ... }), and "normal" PHP arrays to JSON arrays ([ ... ]).
The practical upshot is that "normal" PHP arrays are generally only valid when inside an associative array, like when specifying multiple values for a field. The following example from the PHP Mongo manual shows a valid usage of a "normal" array in a query:
$cursor = $collection->find(array("awards" => array('$in' => array("gold", "copper"))));