Sort query results by nested association in cakephp 3 - php

The Problem:
I have a cakephp 3.x query object with two nested associations, Organizations.Positions.Skills, that is being set to a view variable, $Organizations. I'm trying to sort the query's resulting top level array by a column in the first nested association. That is, I want to sort $Organizations by a column in Positions, specifically Positions.Ended).
public function index()
{
$this->loadModel('Organizations');
$this->set('Organizations',
$this->Organizations->find('all')
->contain([ //eager loading
'Positions.Skills'
])
);
}
Model Info
Organizations has many Positions
Positions has many Skills
Research I've Done
order option
According to the cookbook find() has an order option: find()->order(['field']); or find('all', ['order'=>'field DESC']);
However, this only applies to fields in the table find() is being called upon. In this case, Organizations. For example, this is how it's typically used.
//This works.
//Sorts Organizations by the name of the organization in DESC.
$this->loadModel('Organizations');
$this->set('Organizations',
$this->Organizations->find('all')
->contain([ //eager loading
'Positions.Skills'
])
->order(['organization DESC'])
);
but, trying to use it for nested associations doesn't work:
$this->set('Organizations',
this->Organizations->find(...)
->contain(...)
->order(['Organizations.Positions.ended DESC'])
);
Error: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'Organizations.Positions.ended' in 'order clause'
and altering it to refer to the field that'll be nested doesn't work either:
//also doesn't work.
$this->set('Organizations',
$this->Organizations->find(...)
->contain(...)
->order([Positions.ended DESC])
);
Error: SQLSTATE[42S22]: Column not found: 1054 Unknown column 'Positions.ended' in 'order clause'
In both cases, the sql error is created when cakephp executes the PDO statement generated by the query.
sort option
Similarly, according to the cookbook, eager loading / associations has the 'sort' option:
$this->loadModel('Organizations');
$this->set('Organizations',
$this->Organizations->find('all')
->contain([ //eager loading
'Positions.Skills',
'Positions' => [
'sort' => ['Positions.ended'=>'DESC']
]
])
);
But, this only sorts the nested association.Specifically, it sorts the associations that are nested. It does not sort the entire resulting set by the nested association (ergo, a multidimensional sort).
For example:
The Organization, Organization C (org id 1), has two positions:
Position 5. Ended 2012
Position 4. Ended 2014
And the Organization, Organization B (org id 2), has two positions:
Position 3 Ended 2013
Position 2 Ended 2015
The above code and data results in the following array being generated when the query is evaluated:
Organizations => [
0 => [
'organization' => 'Organization A',
positions => [
0 => [
'position' => 'Position 1',
'ended' => '2016'
]
]
],
1 => [
'organization' => 'Organization C',
'positions' => [
0 => [
'position' => 'Position 4',
'ended' => '2014'
],
1 => [
'position' => 'Position 5',
'ended' => '2012'
]
]
],
2 => [
'organization' => 'Organization B',
'positions' => [
0 => [
'position' => 'Position 2',
'ended' => '2015'
],
1 => [
'position' => 'Position 3',
'ended' => '2013'
]
]
]
]
other research
Likewise the following stackoverflow questions came up in my research:
http://stackoverflow.com/questions/26859700/cakephp-order-not-working
http://stackoverflow.com/questions/17670986/order-by-doesnt-work-with-custom-model-find-while-paginating
http://stackoverflow.com/questions/18958410/cakephp-paginate-and-sort-2nd-level-association
http://stackoverflow.com/questions/34705257/cakephp-paginate-and-sort-hasmany-association
Furthermore, I do know that PHP has its own sorting functions like sort() and multisort(); but, those can only be called once the query has been evaluated (by foreach). Alternatively, there's calling $organization->toArray() then using multisort; but, this would have to be done in the view, would break the MVC convention of separations of concerns (data and queries are manipulated by the controller and model, not the view!), and would be quite inefficient as it'll be called while the page is loading.
How then, do I sort a cakephp query by its nested associations?
Or, put more simply, how do I order/sort the query to produce the following array upon evaluation:
Organizations => [
0 => [
'organization' => 'Organization A',
'positions' => [
0 => [
'position' => 'Position 1',
'ended' => '2016'
]
]
],
0 => [
'organization' => 'Organization B',
'positions' => [
0 => [
'position' => 'Position 2',
'ended' => '2015'
],
1 => [
'position' => 'Position 3',
'ended' => '2013'
]
]
],
1 => [
'organization => 'Organization C',
'positions' => [
0 => [
'position' => 'Position 4',
'ended' => '2014'
],
1 => [
'position' => 'Position 5',
'ended' => '2012'
]
]
]
]
Background & Context:
I'm building a [portfolio website][7] for myself with cakephp 3.2 to showcase my web dev skills and assist in my quest for a dev career. For my resume page, I'm organizing the massive amount of data with nested accordions to mimic the resume style recruiters would expect to see on an actual resume. As a result, my view does the following:
Looping through the top level view variable (Organizations)
Rendering the organization details
Looping through that organization's positions (still inside 1)
render the position details
loop through the position's relevant skills
render each skill w/ the appropriate link to filter by that skill.
List item

Only hasOne and belongsTo associations are being retrieved via a join on the main query. hasMany associations are being retrieved in a separate queries, hence the errors when you try to refer to a field of Positions.
What you want should be fairly easy to solve, on SQL level, as well as on PHP level.
SQL level
On SQL level you could join in Positions, and order by a computed max(Positions.ended) column, like:
$this->Organizations
->find('all')
->select(['max_ended' => $this->Organizations->query()->func()->max('Positions.ended')])
->select($this->Organizations)
->contain([
'Positions.Skills',
'Positions' => [
'sort' => ['Positions.ended' => 'DESC']
]
])
->leftJoinWith('Positions')
->order([
'max_ended' => 'DESC'
])
->group('Organizations.id');
And that's all, that should give you the results that you want. The query will look something like:
SELECT
MAX(Positions.ended) AS max_ended,
...
FROM
organizations Organizations
LEFT JOIN
positions Positions
ON Organizations.id = (
Positions.organization_id
)
GROUP BY
Organizations.id
ORDER BY
max_ended DESC
PHP level
On PHP level it's also pretty easy to solve to using collections (note that queries are collections - kind of), however it would only make sense if you'd intend to retrieve all rows, or must deal with a set of unordered rows for whatever reason... something like:
$Organizations->map(function ($organization) {
$organization['positions'] =
collection($organization['positions'])->sortBy('ended')->toList();
return $organization;
});
$sorted = $sorted->sortBy(function ($organization) {
return
isset($organization['positions'][0]['ended']) ?
$organization['positions'][0]['ended'] : 0;
});
This could also be implemented in a result formatter, so things happen on controller or model level if you insist.
$query->formatResults(function ($results) {
/* #var $results \Cake\Datasource\ResultSetInterface|\Cake\Collection\CollectionInterface */
$results = $results->map(function ($row) {
$row['positions'] =
collection($row['positions'])->sortBy('ended')->toList();
return $row;
});
return $results->sortBy(function ($row) {
return isset($row['positions'][0]['ended']) ?
$row['positions'][0]['ended'] : 0;
});
});
See
Cookbook > Database Access & ORM > Query Builder > Using SQL Functions
Cookbook > Database Access & ORM > Query Builder > Using leftJoinWith
Cookbook > Database Access & ORM > Query Builder > Queries Are Collection Objects
Cookbook > Database Access & ORM > Query Builder > Adding Calculated Fields
Cookbook > Collections > Sorting

This worked for me:
$this->Organizations->find('all')
->contain(['Positions' => ['sort' => ['Positions.ended' => 'DESC']]])
->contain('Postions.Skills');

Related

Select one image from product (SQL query builder Yii2)

the question is about SQL and Yii2 in particular.
There are three tables:
Product {id, title}
Image {id, url}
Product_Image {product_id, image_id, main}
I'm trying to use the query builder to form a query in such a way that the result is a table like this:
Result {product_id, title, main_image}
Columns product_id, title - from the Product table
Column main_image - should contain an entry from image.url where column main = 1, otherwise return the first found image or NULL
Product::find()
->select([
'product.id AS product_id',
'product.title AS title',
'image.image_url AS main_image',
])
->leftJoin('product_image', 'product_image.product_id = product.id')
->leftJoin('image', 'image.id = product_image.image_id')
->asArray()
->all();
This query returns a lot of the same product_id if the product has multiple images. I need to have only 1 entry for each product and there was either a link to the main image, or if there is no main, then the first entry that came across, or NULL
The ideal look is:
[
0 => [
'product_id' => '1'
'title' => 'Product 1'
'main_image' => null
]
1=>[
'product_id' => '2'
'title' => 'Product 2'
'main_image' => '/images/products/2.jpg'
]
2=>[
'product_id' => '3'
'title' => 'Product 3'
'main_image' => '/images/products/3.jpg'
]
]
I tried adding
andWhere(['product_image.main' => 1])
but in that case all products that don't have a 'main' image disappear.
Please help me, what do I need to add to the SQL request (MySQL)?
UPD:
->joinWith('productImages')
->joinWith('productImages.image');
This approach searches for all existing images and puts them in the "productImages" array. I don't have to search all the images for every product.

How to aggregate by dynamic or unknown fields in Elasticsearch 6.x

I'm fairly new to ElasticSearch, currently using v6.2 and I seem to have run into a problem while trying to add some aggregations to a query. Trying to wrap my head around the various types of aggregation, as well as the best ways to store the data.
When the query runs, I have some variable attributes that I would like to aggregate and then return as filters to the user. For example, one character may have attributes for "size", "shape" and "colour", while another only has "shape" and "colour".
The full list of attributes is unknown so I don't think I would be able to construct the query that way.
My data is currently structured like this:
{
id : 1,
title : 'New Character 1',
group : 1,
region : 1,
attrs : [
moves : 2,
# These would be dynamic, would only apply to some rows, not others.
var_colours : ['Blue', Green', 'Red'],
var_shapes : ['Round', 'Square', 'Etc'],
effects : [
{ id : 1, value: 20},
{ id : 2, value: 60},
{ id : 3, value: 10},
]
]
}
I currently have an aggregation of groups and regions that looks like this. It seems to be working wonderfully and I would like to add something similar for the attributes.
[
'aggs' => [
'group_ids' => [
'terms' => [
'field' => 'group',
'order' => [ '_count' => 'desc' ]
]
],
'region_ids' => [
'terms' => [
'field' => 'region',
'order' => [ '_count' => 'desc' ]
]
]
]
]
I'm hoping to get a result that looks like the below. I am also not sure if the data structure is setup in the best way either, I can make changes there if necessary.
[aggregations] => [
[groups] => [
[doc_count_error_upper_bound] => 0
[sum_other_doc_count] => 0
[buckets] => [
[0] => [
[key] => 5
[doc_count] => 27
],
[1] => [
[key] => 2
[doc_count] => 7
]
]
],
[var_colours] => [
[doc_count_error_upper_bound] => 0
[sum_other_doc_count] => 0
[buckets] => [
[0] => [
[key] => 'Red'
[doc_count] => 27
],
[1] => [
[key] => 'Blue'
[doc_count] => 7
]
]
],
[var_shapes] => [
[doc_count_error_upper_bound] => 0
[sum_other_doc_count] => 0
[buckets] => [
[0] => [
[key] => 'Round'
[doc_count] => 27
],
[1] => [
[key] => 'Polygon'
[doc_count] => 7
]
]
]
// ...
]
Any insight that anyone could provide would be extremely appreciated.
You should do this within your PHP script.
I can think of the following:
Use the Dynamic field mapping for your index.
By default, when a previously unseen field is found in a document, Elasticsearch will add the new field to the type mapping. This behaviour can be disabled, both at the document and at the object level, by setting the dynamic parameter to false (to ignore new fields) or to strict (to throw an exception if an unknown field is encountered).
Get all the existing fields in your index. Use the Get mapping API for this.
Loop over the results of Step 2 so you can get all the existing fields in your index. You can store them in a list (or array), for example.
You can create a PHP Elasticsearch terms aggregation for each of the fields in your list (or array). This is: create an empty or base query with no terms aggregation and add one terms for each element you got from step 3.
Add to each terms, the missing field with an empty empty string ("").
That's it. Following this, you have creating a query in such way that, no matter what index you're searching, you'll get a terms agg with all the existing fields for it.
Advantages:
Your terms aggregations will be generated dynamically with all the existing fields.
For each of the doc that does not contain any of the fields, an empty string will be shown.
Disadvantages:
Looping through the GET mapping API's result could be a little frustrating (but I trust you).
Performance (time & resources) will be affected for every new field you find in your mappings.
I hope this is helpful! :D

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.

CakePHP group and count

I am having trouble figuring this out.
I have the following 3 tables: -
Transactions Table
ID
Date
List item
Products Table
ID
Name
Price
Products_Transactions Table
Transaction_ID
Product_ID
Quantity
So the relationship is as follows - a transaction is made, and then the products_transactions table joins them together since a transaction can have multiple products and a product can have multiple transactions. The join_table also keeps track of the amount sold, so, for instance, a newspaper sells in transaction #1 and with a quantity of 2 (so 2 newspapers sold).
Now, I want to make a MySQL statement that finds all products sold, in a specific date interval, so I get something like this: -
3 x Newspapers
12 x Sodas
15 x Beer
So, it just counts and sums up all the products sold.
I have seriously tried everything - I am working with CakePHP so a solution provided in that would be helpful, but even just the plain SQL to achieve this might help me out.
So far, this is what I have: -
$productTransactionsTable = TableRegistry::get('products_transactions');
$productsTransactions = $productTransactionsTable->find('all');
$productsTransactions->matching('transactions', function ($q) {
return $q->where([
'transaction_date >=' => new \DateTime('-1 week'),
'transaction_date <=' => new \DateTime('now'),
'device_id IN' => $this->deviceIdsInDepartment(2)
]);
});
$productsTransactions->contain(['products']);
$productsTransactions->select([
'count' => $productsTransactions->func()->count('quantity'),
'name' => 'products.name'
]);
$productsTransactions->groupBy('products.id');
But this just gives out 1 single result that counts everything together into 1 row, like this:
/src/Controller/EconomyController.php (line 665)
[
(int) 0 => object(Cake\ORM\Entity) {
'count' => (int) 4504,
'name' => 'D Morgenbrød',
'[new]' => false,
'[accessible]' => [
'*' => true
],
'[dirty]' => [],
'[original]' => [],
'[virtual]' => [],
'[errors]' => [],
'[invalid]' => [],
'[repository]' => 'products_transactions'
}
]
Any help is appreciated! I am seriously stuck here!
Thank you!
I think you are counting the total number of sold quantities and grouping so you are getting above results. I think, you need to try the following approach:
$data = $query
->select([
'name' => 'products.name',
'quantity' => 'products.quantity',
])
->group('products.id');

Laravel sync Relation with optional parameters

I use the sync function for syncing a belongsToMany Relation:
$model->products()->sync($productIds);
In the $productIds array there is flat array with some Id's -
something like this:
$productIds = [1,3,5,6];
What I want:
The pivot table has also additional columns like "created_by" and "updated_by".
But how can I add these fields to my array WITHOUT doing a foreach loop?
Is there a shorter way to do this?
I need an array like this:
$productIds = [1 => [
'created_by' => 1,
'updated_by' => 1
],3 => [
'created_by' => 1,
'updated_by' => 1
],5 => [
'created_by' => 1,
'updated_by' => 1
],6 => [
'created_by' => 1,
'updated_by' => 1
]];
Yes I know I can do it with foreach and add the columns while I loop through the array. But I want do it shorter.. is there a way to do it shorter (perhaps with laravel)?
It should be enough to pass what you have set in $productIds in your code example to sync().
This method works not only with array of integers. You can also pass an array where key is the synced ID and value is the array of pivot attributes that should be set for given ID.
This should do the trick:
$productIds = [
1 => [
'created_by' => 1,
'updated_by' => 1
]
//rest of array
];
$model->products()->sync($productIds);
Just make sure you have defined those fields as pivot fields in your relation definition.
In order to generate such table based on a list of IDs in $productIds you can do the following:
$productIds = array_fill_keys($productIds, array(
'created_by' => 1,
'updated_by' => 1,
));

Categories