CakePHP 3 - How to write COALESCE(...) in query builder? - php

How do I write this kind of COALESCE() statement in the query builder?
SQL
SELECT COALESCE(n.value, p.value) AS value
FROM nodes n
LEFT JOIN parents p ON p.id = n.parent_id
PHP
I can retrieve both the child and parent values and then go through the result set and just use the parent one if the child one is empty, but if there is a more elegant way to build it into the query itself, I would prefer that.
$child = $this->Nodes->find()
->select(['id', 'value'])
->where(['Nodes.id' => $id])
->contain([
'Parents' => function ($q) {
return $q->select('value');
}
])
->first();
if (empty($child->value)) {
$child->value = $child->parent->value;
}
Update 1
So this is what I have at the moment, but it doesn't work.
$child = $this->Nodes->find()
->select(['id', 'value'])
->where(['Nodes.id' => $id])
->contain([
'Parents' => function ($q) {
return $q->select([
'value' => $q->func()->coalesce([
'Nodes.value',
'Parents.value'
])
]);
}
])
->first();
Returns:
object(Cake\ORM\Entity) {
'id' => (int) 234,
'value' => (float) 0,
'[new]' => false,
'[accessible]' => [
'*' => true
],
'[dirty]' => [],
'[original]' => [],
'[virtual]' => [],
'[errors]' => [],
'[invalid]' => [],
'[repository]' => 'Nodes'
}
The child value is NULL and the parent value is 1.00 so I would expect the entity value to be 'value' => (float) 1.00 but I assume it's coming out of the query as FALSE converted to (float) 0.
Update 2
It seems aliasing the coalesce to a name which already exists as a normal field does not work. It requires a unique field name for the coalesce result.
Update 3
I did another test and selected the name field from the two tables instead, and it just returns the actual strings I entered into the function (they do not get evaluated as column names):
return $q->select([
'value' => $q->func()->coalesce([
'Nodes.name',
'Parents.name'
])
]);
The returned entity has:
'value' => 'Nodes.name'
So my new question would be how to get the query builder to evaluate the strings as table/field names?

I could not get Cake's coalesce() function to evaluate the parameters as fields, it was just returning the actual strings of the field names.
I got it working by manually creating the COALESCE statement instead.
// Create the query object first, so it can be used to create a SQL expression
$query = $this->Nodes->find();
// Modify the query
$query
->select([
'id',
'value' => $query->newExpr('COALESCE(Nodes.value, Parents.value)')
])
->where(['Nodes.id' => $id])
->contain('Parents')
->first();

CakePHP's coalesce() uses the following format to differentiate field names from literal values:
'value' => $q->func()->coalesce([
'User.last_name' => 'identifier',
', ',
'User.first_name' => 'identifier'
])
The code above should yield results like Smith, John.
The default behavior is to treat the element as a literal.
See https://api.cakephp.org/3.3/class-Cake.Database.FunctionsBuilder.html#_coalesce
The docs don't explain this well at all. H/T to https://www.dereuromark.de/2020/02/06/virtual-query-fields-in-cakephp/ for a clear example.

See http://book.cakephp.org/3.0/en/orm/query-builder.html#using-sql-functions
Haven't tried it but I guess it's:
$child = $this->Nodes->find()
->select(['id', 'value'])
->where(['Nodes.id' => $id])
->contain([
'Parents' => function ($q) {
return $q->select(['value' => $query->func()->coalesce([
/* Fields go here... I think. :) */
])]);
}
])
->first();
If this isn't working check the unit tests of the core how to call this function.

Related

Laravel 8.x - How to use updateOrCreate with a where clause?

I am trying to use Laravel updateOrCreate to update a record if its within this month and last month based on if an ID exists.
\App\Models\Result::updateOrCreate(
[
'id' => $request->get('id'),
// where created_at is within the last 2 months
'created_at' => [
'>',
\Carbon\Carbon::now()->startOfMonth()->subMonthsNoOverflow()
],
],
[
'discipline_one' => $request->get('d-one'),
'discipline_two' => $request->get('d-two'),
'discipline_three' => $request->get('d-three'),
'discipline_four' => $request->get('d-four'),
'discipline_five' => $request->get('d-five'),
'discipline_six' => $request->get('d-six'),
]
);
If the ID exists, and the result is in this current month or last, a new record is created instead of updated.
Expected input: 1 (which exists from last month)
Expected output: Record is updated
Expected input: 2 (which doesn't exist or is not within 2 months)
Expected output: New record is created
Update:
Using the answer suggested,
'id' => $request->get('id'),
'created_at' => \App\Models\Result::whereBetween('created_at', [
\Carbon\Carbon::now()->startOfMonth()->subMonthsNoOverflow(),
\Carbon\Carbon::now()->endOfMonth(),
]),
I get the error:
Object of class Illuminate\Database\Eloquent\Builder could not be converted to string
Here you go:
You only need to explicitly define the operators in the method's first parameter.
Each element of the array in the method's first parameter should be an array containing the three arguments.
column, operator, value
$startDate = \Carbon\Carbon::now()->startOfMonth()->subMonths(2);
$endDate = \Carbon\Carbon::now()->endOfMonth();
$resultRow = \App\Models\Result::query()
->where('id', $request->id)
->whereBetween('created_at', [$startDate, $endDate])
->first();
\App\Models\Result::updateOrCreate(
[
[
'id', '=', $request->get('id'),
],
[
'created_at', '>=', $startDate,
],
[
'created_at', '<=', $endDate,
],
],
array_merge(
[
'discipline_one' => $request->get('d-one'),
'discipline_two' => $request->get('d-two'),
'discipline_three' => $request->get('d-three'),
'discipline_four' => $request->get('d-four'),
'discipline_five' => $request->get('d-five'),
'discipline_six' => $request->get('d-six'),
],
$resultRow ? [] : [
'id' => $request->get('id'),
'created_at' => \Carbon\Carbon::now()
]
)
);
Notice that I repeated id & created_at column settings in the method's second parameter. It's because contrary to the regular behaviour, a merge of the 2 method's parameter arrays wasn't performed based on my tests if no result was found with that criteria.

Eloquent recursive relation

I have an issue where I'm trying to get all descendants of an object and keep only those with a specific property.
I have these relations:
public function getChildren()
{
return $this->hasMany(self::class, 'parent_id', 'id');
}
public function allChildren()
{
return $this->getChildren()->with('allChildren');
}
And I get this type of array for example:
$array = [
0 => ['name' => 'aaa', 'type' => 0, 'parent' => null, 'children' => [
1 => ['name' => 'bbb', 'type' => 1, 'parent' => null, 'children' => []],
2 => ['name' => 'ccc', 'type' => 0, 'parent' => null, 'children' => [
3 => ['name' => 'ddd', 'type' => 1, 'parent' => 2, 'children' => []]
]]
]],
4 => ['name' => 'eee', 'type' => 0, 'parent' => null, 'children' => []]
];
For this example, I would like to remove all objects that are of type 1 and get a clean array without those only.
I don't really understand why it is possible to get all descendats of an object but not be able to pass conditions.
Thanks in advance.
A collection only solution would be something like this (place the custom macro in a Service Provider of your application):
Collection::macro('whereDeep', function ($column, $operator, $value, $nested) {
return $this->where($column, $operator, $value)->map(function ($x) use ($column, $operator, $value, $nested) {
return $x->put($nested, $x->get($nested)->whereDeep($column, $operator, $value, $nested));
});
});
Then where needed call:
$yourArray->whereDeep('type', '!=', 1, 'children');
On your example, the macro works like this:
Filter all the elements where: type != 1
(the outer array will beuntouched as both items has type => 0)
For each element of the current array:
Retrive the children property and apply the same filtering to this subarray starting with the first point of this instructions.
Replace the children property with the new children property just filtered.
Anyways, you should try to deep dive into why the relation filtering doesn't work. That solution would be more efficient if optimized correctly.
I found a great solution where there is no need of all this recursion or any of these relationship calls so I share it:
Using: "gazsp/baum"
// get your object with roots method
$contents = Content::roots()->get();
// and simply run through the object and get whatever you need
// thanks to getDescendantsAndSelf method
$myArray = [];
foreach($contents as $content) {
$myArray[] = $content->getDescendantsAndSelf()->where('type', '!=', 1)->toHierarchy();
}
return $myArray;
This works for me the same way as the other method above.

Dynamically add columns to query results via CakePHP 3 ORM queries

I'm trying to write a query using CakePHP 3.7 ORM where it needs to add a column to the result set. I know in MySQL this sort of thing is possible: MySQL: Dynamically add columns to query results
So far I've implemented 2 custom finders. The first is as follows:
// src/Model/Table/SubstancesTable.php
public function findDistinctSubstancesByOrganisation(Query $query, array $options)
{
$o_id = $options['o_id'];
$query = $this
->find()
->select('id')
->distinct('id')
->contain('TblOrganisationSubstances')
->where([
'TblOrganisationSubstances.o_id' => $o_id,
'TblOrganisationSubstances.app_id IS NOT' => null
])
->orderAsc('Substances.app_id')
->enableHydration(false);
return $query;
}
The second custom finder:
// src/Model/Table/RevisionSubstancesTable.php
public function findProductNotifications(Query $query, array $options)
{
$date_start = $options['date_start'];
$date_end = $options['date_end'];
$query = $this
->find()
->where([
'RevisionSubstances.date >= ' => $date_start,
'RevisionSubstances.date <= ' => $date_end
])
->contain('Substances')
->enableHydration(false);
return $query;
}
I'm using the finders inside a Controller to test it out:
$Substances = TableRegistry::getTableLocator()->get('Substances');
$RevisionSubstances = TableRegistry::getTableLocator()->get('RevisionSubstances');
$dates = // method to get an array which has keys 'date_start' and 'date_end' used later.
$org_substances = $Substances->find('distinctSubstancesByOrganisation', ['o_id' => 123);
if (!$org_substances->isEmpty()) {
$data = $RevisionSubstances
->find('productNotifications', [
'date_start' => $dates['date_start'],
'date_end' => $dates['date_end']
])
->where([
'RevisionSubstances.substance_id IN' => $org_substances
])
->orderDesc('RevisionSubstances.date');
debug($data->toArray());
}
The logic behind this is that I'm using the first custom finder to produce a Query Object which contains unique (DISTINCT in SQL) id fields from the substances table, based on a particular company (denoted by the o_id field). These are then fed into the second custom finder by implementing where(['RevisionSubstances.substance_id IN' ....
This works and gives me all the correct data. An example of the output from the debug() statement is as follows:
(int) 0 => [
'id' => (int) 281369,
'substance_id' => (int) 1,
'date' => object(Cake\I18n\FrozenDate) {
'time' => '2019-09-02T00:00:00+00:00',
'timezone' => 'UTC',
'fixedNowTime' => false
},
'comment' => 'foo',
'substance' => [
'id' => (int) 1,
'app_id' => 'ID000001',
'name' => 'bar',
'date' => object(Cake\I18n\FrozenDate) {
'time' => '2019-07-19T00:00:00+00:00',
'timezone' => 'UTC',
'fixedNowTime' => false
}
]
],
The problem I'm having is as follows: Each of the results returned contains a app_id field (['substance']['app_id'] in the array above). What I need to do is perform a count (COUNT() in MySQL) on another table based on this, and then add that to the result set.
I'm unsure how to do this for a couple of reasons. Firstly, my understanding is that custom finders return Query Objects, but the query is not executed at this point. Because I haven't executed the query - until calling $data->toArray() - I'm unsure how I would refer to the app_id in a way where it could be referenced per row?
The equivalent SQL that would give me the required results is this:
SELECT COUNT (myalias.app_id) FROM (
SELECT
DISTINCT (tbl_item.i_id),
tbl_item.i_name,
tbl_item.i_code,
tbl_organisation_substances.o_id,
tbl_organisation_substances.o_sub_id,
tbl_organisation_substances.app_id,
tbl_organisation_substances.os_name
FROM
tbl_organisation_substances
JOIN tbl_item_substances
ON tbl_organisation_substances.o_sub_id = tbl_item_substances.o_sub_id
JOIN tbl_item
ON tbl_item.i_id = tbl_item_substances.i_id
WHERE
tbl_item.o_id = 1
AND
tbl_item.date_valid_to IS NULL
AND
tbl_organisation_substances.app_id IS NOT NULL
ORDER BY
tbl_organisation_substances.app_id ASC
) AS myalias
WHERE myalias.app_id = 'ID000001'
This does a COUNT() where the app_id is ID000001.
So in the array I've given previously I need to add something to the array to hold this, e.g.
'substance' => [
// ...
],
'count_app_ids' => 5
(Assuming there were 5 rows returned by the query above).
I have Table classes for all of the tables referred to in the above query.
So my question is, how do you write this using the ORM, and add the result back to the result set before the query is executed?
Is this even possible? The only other solution I can think of is to write the data (from the query I have that works) to a temporary table and then perform successive queries which UPDATE with the count figure based on the app_id. But I'm really not keen on that solution because there are potentially huge performance problems of doing this. Furthermore I'd like to be able to paginate my query so ideally need everything confined to 1 SQL statement, even if it's done across multiple finders.
I've tagged this with MySQL as well as CakePHP because I'm not even sure if this is achievable from a MySQL perspective although it does look on the linked SO post like it can be done? This has the added complexity of having to write the equivalent query using Cake's ORM.

Why is the `LIKE` operator not working with integer columns?

I'm trying to receive some ids from my database for an autocomplete search on my CAKEPHP 3.3 site. But my problem is that its only returning the id if I type in the exact id and not part of it.
Here is my function to search the data. The name variable is what is being passed from input.
public function search()
{
if ($this->request->is('ajax'))
{
$name = $this->request->query['term'];
$resultArr = $this->Invoices->find('all', [
'conditions' => ['Invoices.id LIKE' => ($name . '%')]
]);
$resultsArr = [];
foreach ($resultArr as $result)
{
$resultsArr[] = ($result['id']);
}
$this->set('resultsArr', $resultsArr);
// This line is what handles converting your array into json
// To get this to work you must load the request handler
$this->set('_serialize', ['resultsArr']);
}
}
For example there is a id in the table '5254' and I type in part of the id '52' nothing is returned but when I type in the whole id '5254' the id is returned.
I'm unsure why this is the case because in my sql query i'm using the percent sign to say any characters after what has been typed into the input.
Here is part of my table
SQL debug when 52 is entered.
object(Cake\ORM\Query) {
'(help)' => 'This is a Query object, to get the results execute or iterate it.',
'sql' => 'SELECT Invoices.id AS `Invoices__id`, Invoices.start_date AS `Invoices__start_date`, Invoices.close_date AS `Invoices__close_date`, Invoices.customer_id AS `Invoices__customer_id`, Invoices.invoice_to_address AS `Invoices__invoice_to_address`, Invoices.ship_to_address AS `Invoices__ship_to_address`, Invoices.customer_contact_id AS `Invoices__customer_contact_id`, Invoices.aircraft_registration_id AS `Invoices__aircraft_registration_id`, Invoices.shipping_company_id AS `Invoices__shipping_company_id`, Invoices.notes AS `Invoices__notes`, Invoices.worksheet_notes AS `Invoices__worksheet_notes`, Invoices.closed AS `Invoices__closed`, Invoices.times_printed AS `Invoices__times_printed`, Invoices.payment_due AS `Invoices__payment_due`, Invoices.GST_rate AS `Invoices__GST_rate`, Invoices.opening_notes AS `Invoices__opening_notes`, Invoices.courier_ticket AS `Invoices__courier_ticket`, Invoices.job_description AS `Invoices__job_description`, Invoices.worksheets_printed AS `Invoices__worksheets_printed`, Invoices.supervising_engineer_id AS `Invoices__supervising_engineer_id`, Invoices.job_type_id AS `Invoices__job_type_id`, Invoices.opened_by_id AS `Invoices__opened_by_id`, Invoices.assigned_to_id AS `Invoices__assigned_to_id`, Invoices.certification_required AS `Invoices__certification_required`, Invoices.currency_id AS `Invoices__currency_id`, Invoices.xero_batch_number AS `Invoices__xero_batch_number`, Invoices.xero_amount AS `Invoices__xero_amount`, Invoices.exchange_rate AS `Invoices__exchange_rate`, Invoices.payment_instructions AS `Invoices__payment_instructions`, Invoices.email AS `Invoices__email`, Invoices.inv_email AS `Invoices__inv_email` FROM invoices Invoices WHERE Invoices.id like :c0',
'params' => [
':c0' => [
'value' => '52%',
'type' => 'integer',
'placeholder' => 'c0'
]
The id column is of type INTEGER, and therefore the value is being bound as such, as can be seen in your Query dump, it says 'type' => 'integer'. Being bound as an integer will cause it to be casted, and you'll end up with a comparison against 52 only.
You can workaround that by telling the query builder to treat the column as a string type. This can be done via the second argument ($types) of the query builders *where() methods:
$this->Invoices
->find()
->where(
['Invoices.id LIKE' => ($name . '%')],
['Invoices.id' => 'string']
);
See also
API > \Cake\ORM\Query::where()
In this case You can "inject" plain query - array values with numeric index in conditions are treated as plain query, and it will not be parametrized. Be carefull: Typecast to integer is necessary in this case to prevent SQL Injection:
$result = $this->Invoinces->find('all' , [
'conditions' => [
'id LIKE "'.(int)$input.'%" '
]
])
->toArray();
Try it like this:
'conditions' => ['Invoices.id LIKE' => '"' . $name . '%"']
you can still do it like this in cakephp 3
$results = $clients->find()->select(['id','email','name','accountid','created','status'])
->Where(function (QueryExp $exp, Query $q) use ($requestData) {
$orCond = $exp->or_([
new Comparison('accountid',$requestData['search']['value'],null,'LIKE'),
new Comparison('email',$requestData['search']['value'],null,'LIKE'),
new Comparison('name',$requestData['search']['value'],null,'LIKE'),
new Comparison('created',$requestData['search']['value'],null,'LIKE'),
new Comparison('status',$requestData['search']['value'],null,'LIKE'),
]);
return $exp->add($orCond);
});

how to use not null condition in YII2

Hi i want to use not null condition in my yii2 query how should i use that.
i don't want city and state null.
My query is
$query = new Query;
$query->select('ID, City,State,StudentName')
->from('student')
->where(['IsActive' => 1])
->orderBy(['rand()' => SORT_DESC])
->limit(10);
$dataProvider = new ActiveDataProvider([
'query' => $query,
'pagination' => false,
]);
You can use the not operator combined with the fields that should not be null to generate a IS NOT NULL SQL statement. Like this:
$query = new Query;
$query->select('ID, City,State,StudentName')
->from('student')
->where(['IsActive' => 1])
->andWhere(['not', ['City' => null]])
->andWhere(['not', ['State' => null]])
->orderBy(['rand()' => SORT_DESC])
->limit(10);
Also check the examples in the documentation.
->where(['IS NOT', 'column', null]);
get
WHERE column IS NOT NULL
You can also use, it is faster to type
->where('column IS NOT NULL')
In complex query
->where(['AND',
'column1 IS NOT NULL', // works
['IS NOT', 'column2', null], // works
['column3' => $value],
)
One of the options will be:
$query = new Query;
$query->select('ID, City,State,StudentName')
->from('student')
->where(['IsActive' => 1])
->andWhere(['<>', 'City', null])
->andWhere(['<>', 'State', null])
->orderBy(['rand()' => SORT_DESC])
->limit(10);
Check official docs for where.
$items = BadOffer::find()->where(['OR',
['IS', 'moderator_id', (new Expression('Null'))],
['moderator_id' => $user->id],
]);
In Yii2, we can use any of the queries below to verify the null condition,
->andWhere(['NOT', ['city' => null]]);
or
->andWhere(['<>', ['city' => null]]);
or
->andWhere('city IS NOT NULL');
use ->andWhere(['not', ['State' => null]]) or ->andWhere(['is not', 'State', null]);
Do no use :
andFilterWhere, as the null will make this filter be ignored
->andWhere(['<>', 'State', null]) as it form query AND State <> null
How to select non-empty columns in MySQL?
use LENGTH :
SELECT col1
FROM table
WHERE LENGTH(col1) > 0
Yii2 :
->andWhere(['>', 'LENGTH(col1)', 0])
This work for me, but only when pass null as string
->andFilterWhere(['<>', '`city`', 'null']);

Categories