Post by bbcentral » Mon Nov 12, 2012 8:55 am

(Reposted from GitHub)

As a vqMod developer, one of the more annoying things in OpenCart are the SQL statements, for the following reasons:
  • They're almost always on one long line
  • The DB_PREFIX is messy and makes the SQL harder to read
  • Instead of assigning and passing PHP variables to SQL, they are called inside the SQL statement themselves.
Example:
catalog\model\catalog\product.php

Code: Select all

$query = $this->db->query("SELECT DISTINCT *, pd.name AS name, p.image, m.name AS manufacturer, (SELECT price FROM " . DB_PREFIX . "product_discount pd2 WHERE pd2.product_id = p.product_id AND pd2.customer_group_id = '" . (int)$customer_group_id . "' AND pd2.quantity = '1' AND ((pd2.date_start = '0000-00-00' OR pd2.date_start < NOW()) AND (pd2.date_end = '0000-00-00' OR pd2.date_end > NOW())) ORDER BY pd2.priority ASC, pd2.price ASC LIMIT 1) AS discount, (SELECT price FROM " . DB_PREFIX . "product_special ps WHERE ps.product_id = p.product_id AND ps.customer_group_id = '" . (int)$customer_group_id . "' AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())) ORDER BY ps.priority ASC, ps.price ASC LIMIT 1) AS special, (SELECT points FROM " . DB_PREFIX . "product_reward pr WHERE pr.product_id = p.product_id AND customer_group_id = '" . (int)$customer_group_id . "') AS reward, (SELECT ss.name FROM " . DB_PREFIX . "stock_status ss WHERE ss.stock_status_id = p.stock_status_id AND ss.language_id = '" . (int)$this->config->get('config_language_id') . "') AS stock_status, (SELECT wcd.unit FROM " . DB_PREFIX . "weight_class_description wcd WHERE p.weight_class_id = wcd.weight_class_id AND wcd.language_id = '" . (int)$this->config->get('config_language_id') . "') AS weight_class, (SELECT lcd.unit FROM " . DB_PREFIX . "length_class_description lcd WHERE p.length_class_id = lcd.length_class_id AND lcd.language_id = '" . (int)$this->config->get('config_language_id') . "') AS length_class, (SELECT AVG(rating) AS total FROM " . DB_PREFIX . "review r1 WHERE r1.product_id = p.product_id AND r1.status = '1' GROUP BY r1.product_id) AS rating, (SELECT COUNT(*) AS total FROM " . DB_PREFIX . "review r2 WHERE r2.product_id = p.product_id AND r2.status = '1' GROUP BY r2.product_id) AS reviews, p.sort_order FROM " . DB_PREFIX . "product p LEFT JOIN " . DB_PREFIX . "product_description pd ON (p.product_id = pd.product_id) LEFT JOIN " . DB_PREFIX . "product_to_store p2s ON (p.product_id = p2s.product_id) LEFT JOIN " . DB_PREFIX . "manufacturer m ON (p.manufacturer_id = m.manufacturer_id) WHERE p.product_id = '" . (int)$product_id . "' AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' AND p.status = '1' AND p.date_available <= NOW() AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "'");
(2408 characters long!)

This is incredibly difficult to follow. It's not impossible, but it is difficult. It makes optimizing and improving the code harder, and could discourage developers from working on the project.

What follows below are my own personal suggestions on how this could be improved. It's not about pushing my own particular programming style onto everyone else, it's just to get the conversation going about how we could make this better.

I'd start by assigning the query to a variable before sending it to the database.

Code: Select all

$sql = "SELECT DISTINCT *, pd.name AS name, p.image, m.name AS manufacturer, (SELECT price FROM " . DB_PREFIX . "product_discount pd2 WHERE pd2.product_id = p.product_id AND pd2.customer_group_id = '" . (int)$customer_group_id . "' AND pd2.quantity = '1' AND ((pd2.date_start = '0000-00-00' OR pd2.date_start < NOW()) AND (pd2.date_end = '0000-00-00' OR pd2.date_end > NOW())) ORDER BY pd2.priority ASC, pd2.price ASC LIMIT 1) AS discount, (SELECT price FROM " . DB_PREFIX . "product_special ps WHERE ps.product_id = p.product_id AND ps.customer_group_id = '" . (int)$customer_group_id . "' AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())) ORDER BY ps.priority ASC, ps.price ASC LIMIT 1) AS special, (SELECT points FROM " . DB_PREFIX . "product_reward pr WHERE pr.product_id = p.product_id AND customer_group_id = '" . (int)$customer_group_id . "') AS reward, (SELECT ss.name FROM " . DB_PREFIX . "stock_status ss WHERE ss.stock_status_id = p.stock_status_id AND ss.language_id = '" . (int)$this->config->get('config_language_id') . "') AS stock_status, (SELECT wcd.unit FROM " . DB_PREFIX . "weight_class_description wcd WHERE p.weight_class_id = wcd.weight_class_id AND wcd.language_id = '" . (int)$this->config->get('config_language_id') . "') AS weight_class, (SELECT lcd.unit FROM " . DB_PREFIX . "length_class_description lcd WHERE p.length_class_id = lcd.length_class_id AND lcd.language_id = '" . (int)$this->config->get('config_language_id') . "') AS length_class, (SELECT AVG(rating) AS total FROM " . DB_PREFIX . "review r1 WHERE r1.product_id = p.product_id AND r1.status = '1' GROUP BY r1.product_id) AS rating, (SELECT COUNT(*) AS total FROM " . DB_PREFIX . "review r2 WHERE r2.product_id = p.product_id AND r2.status = '1' GROUP BY r2.product_id) AS reviews, p.sort_order FROM " . DB_PREFIX . "product p LEFT JOIN " . DB_PREFIX . "product_description pd ON (p.product_id = pd.product_id) LEFT JOIN " . DB_PREFIX . "product_to_store p2s ON (p.product_id = p2s.product_id) LEFT JOIN " . DB_PREFIX . "manufacturer m ON (p.manufacturer_id = m.manufacturer_id) WHERE p.product_id = '" . (int)$product_id . "' AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' AND p.status = '1' AND p.date_available <= NOW() AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "'";
$query = $this->db->query($sql);
The next thing I'd be looking at doing is removing the DB_PREFIX so we don't have to constantly keep breaking out of the PHP string. One possibility is to look at adding the prefix in a more dynamic method at the DB library level.
SQL Before:

Code: Select all

"... LEFT JOIN " . DB_PREFIX . "manufacturer m ON ..."
SQL After:

Code: Select all

"... LEFT JOIN {oc}manufacturer m ON..."
To achieve this, we would need to add a single line of code to the system/library/db.php file:

Code: Select all

public function query($sql) {
	$sql = str_replace("{oc}",DB_PREFIX,$sql); // Added: Replace DB prefix before query execution
	return $this->driver->query($sql);
}
Obviously the "{oc}" variable can be changed to something better. I'm not a fan of the Joomla '#_' variable personally, but it's still better than the way Wordpress does it.

By doing this, the above SQL statement becomes:

Code: Select all

$sql = "SELECT DISTINCT *, pd.name AS name, p.image, m.name AS manufacturer, (SELECT price FROM {oc}product_discount pd2 WHERE pd2.product_id = p.product_id AND pd2.customer_group_id = '" . (int)$customer_group_id . "' AND pd2.quantity = '1' AND ((pd2.date_start = '0000-00-00' OR pd2.date_start < NOW()) AND (pd2.date_end = '0000-00-00' OR pd2.date_end > NOW())) ORDER BY pd2.priority ASC, pd2.price ASC LIMIT 1) AS discount, (SELECT price FROM {oc}product_special ps WHERE ps.product_id = p.product_id AND ps.customer_group_id = '" . (int)$customer_group_id . "' AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())) ORDER BY ps.priority ASC, ps.price ASC LIMIT 1) AS special, (SELECT points FROM {oc}product_reward pr WHERE pr.product_id = p.product_id AND customer_group_id = '" . (int)$customer_group_id . "') AS reward, (SELECT ss.name FROM {oc}stock_status ss WHERE ss.stock_status_id = p.stock_status_id AND ss.language_id = '" . (int)$this->config->get('config_language_id') . "') AS stock_status, (SELECT wcd.unit FROM {oc}weight_class_description wcd WHERE p.weight_class_id = wcd.weight_class_id AND wcd.language_id = '" . (int)$this->config->get('config_language_id') . "') AS weight_class, (SELECT lcd.unit FROM {oc}length_class_description lcd WHERE p.length_class_id = lcd.length_class_id AND lcd.language_id = '" . (int)$this->config->get('config_language_id') . "') AS length_class, (SELECT AVG(rating) AS total FROM {oc}review r1 WHERE r1.product_id = p.product_id AND r1.status = '1' GROUP BY r1.product_id) AS rating, (SELECT COUNT(*) AS total FROM {oc}review r2 WHERE r2.product_id = p.product_id AND r2.status = '1' GROUP BY r2.product_id) AS reviews, p.sort_order FROM {oc}product p LEFT JOIN {oc}product_description pd ON (p.product_id = pd.product_id) LEFT JOIN {oc}product_to_store p2s ON (p.product_id = p2s.product_id) LEFT JOIN {oc}manufacturer m ON (p.manufacturer_id = m.manufacturer_id) WHERE p.product_id = '" . (int)$product_id . "' AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' AND p.status = '1' AND p.date_available <= NOW() AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "'";
(2231 characters long, 177 less than the original)

We can also assign config values to variables:

Code: Select all

$language_id = (int)$this->config->get('config_language_id');
$store_id = (int)$this->config->get('config_store_id');
$group_id = (int)$customer_group_id;
$sql = "SELECT DISTINCT *, pd.name AS name, p.image, m.name AS manufacturer, (SELECT price FROM {oc}product_discount pd2 WHERE pd2.product_id = p.product_id AND pd2.customer_group_id = ".$group_id." AND pd2.quantity = '1' AND ((pd2.date_start = '0000-00-00' OR pd2.date_start < NOW()) AND (pd2.date_end = '0000-00-00' OR pd2.date_end > NOW())) ORDER BY pd2.priority ASC, pd2.price ASC LIMIT 1) AS discount, (SELECT price FROM {oc}product_special ps WHERE ps.product_id = p.product_id AND ps.customer_group_id = ".$group_id." AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())) ORDER BY ps.priority ASC, ps.price ASC LIMIT 1) AS special, (SELECT points FROM {oc}product_reward pr WHERE pr.product_id = p.product_id AND customer_group_id = ".$group_id.") AS reward, (SELECT ss.name FROM {oc}stock_status ss WHERE ss.stock_status_id = p.stock_status_id AND ss.language_id = ".$language_id.") AS stock_status, (SELECT wcd.unit FROM {oc}weight_class_description wcd WHERE p.weight_class_id = wcd.weight_class_id AND wcd.language_id = ".$language_id.") AS weight_class, (SELECT lcd.unit FROM {oc}length_class_description lcd WHERE p.length_class_id = lcd.length_class_id AND lcd.language_id = ".$language_id.") AS length_class, (SELECT AVG(rating) AS total FROM {oc}review r1 WHERE r1.product_id = p.product_id AND r1.status = '1' GROUP BY r1.product_id) AS rating, (SELECT COUNT(*) AS total FROM {oc}review r2 WHERE r2.product_id = p.product_id AND r2.status = '1' GROUP BY r2.product_id) AS reviews, p.sort_order FROM {oc}product p LEFT JOIN {oc}product_description pd ON (p.product_id = pd.product_id) LEFT JOIN {oc}product_to_store p2s ON (p.product_id = p2s.product_id) LEFT JOIN {oc}manufacturer m ON (p.manufacturer_id = m.manufacturer_id) WHERE p.product_id = '" . (int)$product_id . "' AND pd.language_id = ".$language_id." AND p.status = '1' AND p.date_available <= NOW() AND p2s.store_id = ".$store_id."";
$query = $this->db->query($sql);
(1977 characters long, 431 less than the original)

Finally, to make the queries easier to read, I'd look at splitting the SQL over multiple lines.

Code: Select all

$language_id = (int)$this->config->get('config_language_id');
$store_id = (int)$this->config->get('config_store_id');
$group_id = (int)$customer_group_id;
$sql = "SELECT DISTINCT *, pd.name AS name, p.image, m.name AS manufacturer, 
		(SELECT price FROM {oc}product_discount pd2 WHERE pd2.product_id = p.product_id AND pd2.customer_group_id = ".$group_id." AND pd2.quantity = '1' AND ((pd2.date_start = '0000-00-00' OR pd2.date_start < NOW()) AND (pd2.date_end = '0000-00-00' OR pd2.date_end > NOW())) ORDER BY pd2.priority ASC, pd2.price ASC LIMIT 1) AS discount, 
		(SELECT price FROM {oc}product_special ps WHERE ps.product_id = p.product_id AND ps.customer_group_id = ".$group_id." AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())) ORDER BY ps.priority ASC, ps.price ASC LIMIT 1) AS special, 
		(SELECT points FROM {oc}product_reward pr WHERE pr.product_id = p.product_id AND customer_group_id = ".$group_id.") AS reward, 
		(SELECT ss.name FROM {oc}stock_status ss WHERE ss.stock_status_id = p.stock_status_id AND ss.language_id = ".$language_id.") AS stock_status, 
		(SELECT wcd.unit FROM {oc}weight_class_description wcd WHERE p.weight_class_id = wcd.weight_class_id AND wcd.language_id = ".$language_id.") AS weight_class, 
		(SELECT lcd.unit FROM {oc}length_class_description lcd WHERE p.length_class_id = lcd.length_class_id AND lcd.language_id = ".$language_id.") AS length_class, 
		(SELECT AVG(rating) AS total FROM {oc}review r1 WHERE r1.product_id = p.product_id AND r1.status = '1' GROUP BY r1.product_id) AS rating, 
		(SELECT COUNT(*) AS total FROM {oc}review r2 WHERE r2.product_id = p.product_id AND r2.status = '1' GROUP BY r2.product_id) AS reviews, 
		p.sort_order 

		FROM {oc}product p 
		LEFT JOIN {oc}product_description pd ON (p.product_id = pd.product_id) 
		LEFT JOIN {oc}product_to_store p2s ON (p.product_id = p2s.product_id) 
		LEFT JOIN {oc}manufacturer m ON (p.manufacturer_id = m.manufacturer_id) 

		WHERE p.product_id = '" . (int)$product_id . "' 
		AND pd.language_id = ".$language_id." 
		AND p.status = '1' 
		AND p.date_available <= NOW() 
		AND p2s.store_id = ".$store_id."

		LIMIT 1
		";
$query = $this->db->query($sql);
Now the longest line is only 339 characters, and we can easily track down particular parts of the SQL to find problems (for example, if we want to fix or modify the length class.

The rest of the SQL isn't too bad, I added a LIMIT 1 (because we're only ever selecting 1 product, there's no need to continue scanning the table once this product has been found).
Some of those subqueries could also be rewritten as LEFT JOINs, but in order to do that we'd need to make some structural changes (a lot more work).

As I said, these are just my own suggestions to try to improve the code even further. It's already looking much better in the repository, the project is heading in the right direction and most of our client stores are running very fast. We'd like to be able to contribute more functionality over the next year to help get it to the next level.

OpenCart enthusiast
PHP/MySQL/jQuery Developer


User avatar
Newbie

Posts

Joined
Wed Dec 07, 2011 9:26 am

Post by bbcentral » Mon Nov 12, 2012 8:57 am

(Replying to response from opencarthelp)
Replacing the database prefix constant adds another layer of complexity and possible complications to something that's not very difficult in the first place. I don't really see a big need for it.

I disagree with you on input sanitation. It should definitely be kept inline. That prevents you from having to hunt down variables to see if they were properly escaped/cast 30 lines earlier. When they're inline all you have to do is read the query.

The pros and cons of styling are worth a debate. There are some more examples at: http://jonathonhill.net/coding-for-read ... yle-guide/
Revised version keeping the DB_PREFIX and moving the typecasting inline:

Code: Select all

<?php
$language_id = $this->config->get('config_language_id');
$store_id = $this->config->get('config_store_id');
$group_id = $customer_group_id;
$sql = "SELECT DISTINCT *, pd.name AS name, p.image, m.name AS manufacturer, 
        (SELECT price FROM ".DB_PREFIX."product_discount pd2 WHERE pd2.product_id = p.product_id AND pd2.customer_group_id = ".(int)$group_id." AND pd2.quantity = '1' AND ((pd2.date_start = '0000-00-00' OR pd2.date_start < NOW()) AND (pd2.date_end = '0000-00-00' OR pd2.date_end > NOW())) ORDER BY pd2.priority ASC, pd2.price ASC LIMIT 1) AS discount, 
        (SELECT price FROM ".DB_PREFIX."product_special ps WHERE ps.product_id = p.product_id AND ps.customer_group_id = ".(int)$group_id." AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())) ORDER BY ps.priority ASC, ps.price ASC LIMIT 1) AS special, 
        (SELECT points FROM ".DB_PREFIX."product_reward pr WHERE pr.product_id = p.product_id AND customer_group_id = ".(int)$group_id.") AS reward, 
        (SELECT ss.name FROM ".DB_PREFIX."stock_status ss WHERE ss.stock_status_id = p.stock_status_id AND ss.language_id = ".(int)$language_id.") AS stock_status, 
        (SELECT wcd.unit FROM ".DB_PREFIX."weight_class_description wcd WHERE p.weight_class_id = wcd.weight_class_id AND wcd.language_id = ".(int)$language_id.") AS weight_class, 
        (SELECT lcd.unit FROM ".DB_PREFIX."length_class_description lcd WHERE p.length_class_id = lcd.length_class_id AND lcd.language_id = ".(int)$language_id.") AS length_class, 
        (SELECT AVG(rating) AS total FROM ".DB_PREFIX."review r1 WHERE r1.product_id = p.product_id AND r1.status = '1' GROUP BY r1.product_id) AS rating, 
        (SELECT COUNT(*) AS total FROM ".DB_PREFIX."review r2 WHERE r2.product_id = p.product_id AND r2.status = '1' GROUP BY r2.product_id) AS reviews, 
        p.sort_order 

        FROM ".DB_PREFIX."product p 
        LEFT JOIN ".DB_PREFIX."product_description pd ON (p.product_id = pd.product_id) 
        LEFT JOIN ".DB_PREFIX."product_to_store p2s ON (p.product_id = p2s.product_id) 
        LEFT JOIN ".DB_PREFIX."manufacturer m ON (p.manufacturer_id = m.manufacturer_id) 

        WHERE p.product_id = '" . (int)$product_id . "' 
        AND pd.language_id = ".(int)$language_id." 
        AND p.status = '1' 
        AND p.date_available <= NOW() 
        AND p2s.store_id = ".(int)$store_id."

        LIMIT 1
        ";
$query = $this->db->query($sql);
It's not terrible, I'd manage if we kept it as is (but preferably removed the spaces between the double-quotes and fullstops to keep lines shorter).
I do like the readability of the queries in that MySQL style guide, especially when it comes to those complex subqueries. I'm not entirely sure of the best way this could be implemented in OpenCart, possibly something like:

Code: Select all

$sql = "SELECT DISTINCT *, pd.name AS name, p.image, m.name AS manufacturer, 
		(	SELECT price 
			FROM ".DB_PREFIX."product_discount pd2 
			WHERE pd2.product_id = p.product_id 
			AND pd2.customer_group_id = ".(int)$group_id." 
			AND pd2.quantity = '1' 
			AND ((pd2.date_start = '0000-00-00' OR pd2.date_start < NOW()) AND (pd2.date_end = '0000-00-00' OR pd2.date_end > NOW())) 
			ORDER BY pd2.priority ASC, pd2.price ASC LIMIT 1
		) 	AS discount, 
		(	SELECT price ";
// (and so on)

OpenCart enthusiast
PHP/MySQL/jQuery Developer


User avatar
Newbie

Posts

Joined
Wed Dec 07, 2011 9:26 am

Post by JNeuhoff » Tue Dec 11, 2012 6:13 am

Just one general observation on the OpenCart DB queries:

Quite often they use "LEFT JOIN" when "INNER JOIN" would do a better job to make the queries considerably faster.

Export/Import Tool * SpamBot Buster * Unused Images Manager * Instant Option Price Calculator * Number Option * Google Tag Manager * Survey Plus * OpenTwig


User avatar
Guru Member

Posts

Joined
Wed Dec 05, 2007 3:38 am

Who is online

Users browsing this forum: No registered users and 5 guests