Last active
June 8, 2017 15:35
-
-
Save demux/fc8aa0ce3883d7c2cda31578bf07f3df to your computer and use it in GitHub Desktop.
I drafted this proposal to solve the problem of deleting all keys with a certain prefix from `memcached`, but we decided to go with `redis` instead, as I'd previously suggested. This is an incomplete Kohana Controller.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* This class is for requesting and managing reviews for all product types and establishments | |
* | |
* Uri: /products/reviews/<marketplace>?query | |
*/ | |
class Controller_Api_v1_Products_Reviews extends Controller_Api_v1 | |
{ | |
const API_NAME = 'ProductsReviewsService'; | |
/** | |
* @var Model_Locale | |
*/ | |
public $locale = NULL; | |
/** | |
* List of avaialble types and their connector tables | |
* @var array | |
*/ | |
protected $_types = [ | |
// 'type' => 'connector' | |
'tour' => 'tours_reviews', | |
'car' => 'review_cars', | |
'hotel' => 'hotels_reviews', | |
'establishment' => 'establishments_reviews', | |
'attraction' => 'attractions_reviews', | |
'user' => 'users_reviews' | |
]; | |
protected $cache_cleared = FALSE; | |
protected $no_cache = FALSE; | |
/** | |
* 'type' => 'order' pairs, 'order' can be NULL | |
* @var array | |
*/ | |
protected $_post_types = [ | |
'establishment' => 'order_car', | |
'tour' => 'order_tour', | |
'attraction' => NULL, | |
'user' => NULL, | |
'hotel' => 'order_room' | |
]; | |
/** | |
* List of types that use non-default named columns | |
* @var array | |
*/ | |
protected $_columns = [ | |
'car' => 'id' | |
]; | |
/** | |
* Validate and authenticate the request | |
*/ | |
public function before() | |
{ | |
parent::before(); | |
$this->_accepted_methods = ['GET']; | |
if ( ! in_array($this->request->method(), $this->_accepted_methods)) | |
{ | |
$err = new HTTP_Exception_405('HTTP method not accepted'); | |
$err->allowed($this->_accepted_methods); | |
throw $err; | |
} | |
$throw_exceptions = ( ! (Kohana::$environment !== Kohana::PRODUCTION AND $this->request->query('authenticate'))); | |
// Authenticate the request | |
TempAPI::authenticate(self::API_NAME, $this->request->headers(), $throw_exceptions); | |
$marketplace = $this->request->param('marketplace'); | |
if ($marketplace !== self::get_marketplace()) | |
{ | |
throw new HTTP_Exception_400('Bad request - Incorrect marketplace: ' . var_export($marketplace)); | |
} | |
} | |
/** | |
* Parse submitted ordering parameters into an iterable | |
* | |
* @param string $input | |
* | |
* @return iterable[array] | |
* | |
* @author Arnar Yngvason | |
*/ | |
public function parse_ordering($input) | |
{ | |
foreach (explode(',', $input) as $str) | |
{ | |
yield [ltrim($str, '-'), ((substr($str, 0, 1) === '-') ? 'DESC' : 'ASC')]; | |
} | |
} | |
public function guidv4($data) | |
{ | |
assert(strlen($data) == 16); | |
$data[6] = chr(ord($data[6]) & 0x0f | 0x40); // set version to 0100 | |
$data[8] = chr(ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10 | |
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); | |
} | |
public function get_cache_namespace_key($product_type, $product_id) | |
{ | |
$this->cache_namespace_key = vsprintf("%s_%s_products_reviews_%s_%s", [ | |
self::get_marketplace(), | |
$this->locale->id, | |
$product_type, | |
$product_id, | |
]); | |
return $this->cache_namespace_key; | |
} | |
public function get_cache_namespace($product_type, $product_id) | |
{ | |
$key = $this->get_cache_namespace_key($product_type, $product_id); | |
$rand = Cache::instance('default')->get($key, NULL); | |
if ($rand === NULL) | |
{ | |
$rand = $this->guidv4(openssl_random_pseudo_bytes(16)); | |
Cache::instance('default')->set($key, $rand); | |
} | |
$this->cache_namespace = $rand; | |
return $rand; | |
} | |
public function invalidate_cache($product_type, $product_id) | |
{ | |
$key = $this->get_cache_namespace_key($product_type, $product_id); | |
Cache::instance('default')->delete($key); | |
$this->cache_namespace = NULL; | |
} | |
public function generate_cache_key($query) | |
{ | |
$hash = sha1(json_encode(Obj::without([ | |
'marketplace', | |
'locale_id', | |
'product_type', | |
'product_id', | |
], $query))); | |
$this->cache_key = vsprintf('%s_%s', [ | |
$this->get_cache_namespace($query->product_type, $query->product_id), | |
$hash | |
]); | |
return $this->cache_key; | |
} | |
public function cache($query) { | |
if (filter_var(Obj::get($query, 'clear_cache', 'false'), FILTER_VALIDATE_BOOLEAN)) | |
{ | |
$this->invalidate_cache($query->product_type, $query->product_id); | |
$this->cache_cleared = TRUE; | |
} | |
$key = $this->generate_cache_key($query); | |
if (filter_var(Obj::get($query, 'no_cache', 'false'), FILTER_VALIDATE_BOOLEAN)) | |
{ | |
return (object) [ | |
'get' => function () { | |
return NULL; | |
}, | |
'set' => function () use ($key) { | |
return $key; | |
}, | |
]; | |
$this->no_cache = TRUE; | |
} | |
return (object) [ | |
'get' => function () use ($key) { | |
return Cache::instance('default')->get($key, NULL); | |
}, | |
'set' => function ($val) use ($key) { | |
Cache::instance('default')->set($key, $val); | |
return $key; | |
}, | |
]; | |
} | |
/** | |
* Creates database query for fetching the reviews | |
* | |
* Example input object: | |
* (object) [ | |
* 'order_by' => string, // Optional, comma separated list of sort rules. Default "sortorder" | |
* 'product_type' => string, // Optional, type of product we are fetching reviews for | |
* 'product_id' => int, // Optional, id of the product | |
* ] | |
* | |
* @param object $query Query params object | |
* | |
* @throws Exception | |
* @return Iterable | |
*/ | |
public function find_reviews(stdClass $query, Model_Locale $locale = NULL) | |
{ | |
// Begin query | |
$review_db_result = ORM::factory('Review'); | |
$product_type = Obj::get($query, 'product_type'); | |
if ($product_type !== NULL) | |
{ | |
// Find correct connector table and column | |
$connector = Arr::gett($this->_types, $product_type); | |
if ($connector === NULL) | |
{ | |
throw new HTTP_Exception_400('Bad request - Invalid product type: ' . $product_type); | |
} | |
$review_db_result | |
->join([$connector, 'conn']) | |
->on('review.id', '=', 'conn.review_id'); | |
$column = Arr::gett($this->_columns, $product_type, "{$product_type}_id"); | |
$product_id = Obj::get($query, 'product_id'); | |
if ($product_id) | |
{ | |
// Filter on product id: | |
$review_db_result->where("conn.$column", '=', $product_id); | |
} | |
else | |
{ | |
// Filter on specified product type (for example all tour reviews): | |
$review_db_result->where("conn.$column", 'IS NOT', NULL); | |
} | |
} | |
// Set locale filter | |
if ($locale) | |
{ | |
$review_db_result->where('locale_id', '=', $locale->id); | |
} | |
// Set sorting rules | |
$ordering = $this->parse_ordering(Obj::get($query, 'order_by', 'sortorder')); | |
foreach ($ordering as list($field, $direction)) { | |
$review_db_result->order_by($field, $direction); | |
} | |
// Pagination | |
$limit = (int) Obj::get($query, 'limit', 100); | |
$page = (int) Obj::get($query, 'page', 1); | |
$offset = ($page - 1) * $limit; | |
// Count before applying limit and offset: | |
$total_count = $review_db_result->reset(FALSE)->count_all(); | |
// Apply limit and offset: | |
$review_db_result | |
->limit($limit) | |
->offset($offset); | |
return [$review_db_result->find_all(), [ | |
'pages' => ceil($total_count / $limit), | |
'page' => $page, | |
'per_page' => $limit, | |
]]; | |
} | |
/** | |
* Fetch all reviews for a requested product/establishment | |
* | |
* @return object | |
*/ | |
public function action_get() | |
{ | |
// Set locale | |
$locale_id = $this->request->query('locale_id'); | |
if ($locale_id AND ! array_key_exists($locale_id, Model_Locale::$all_locales)) | |
{ | |
throw new HTTP_Exception_400('Bad request - Invalid locale: ' . var_export($locale_id)); | |
} | |
$this->locale = Arr::gett(Model_Locale::$all_locales, $locale_id, NULL); | |
// Fetch GET query | |
$query = (object) $this->request->query(); | |
if ($cached = $this->cache($query)->get->__invoke()) | |
{ | |
$this->return_data($cached); | |
} | |
// Find reviews | |
list($review_db_result, $pagination) = $this->find_reviews($query, $this->locale); | |
// Find comments for all reviews | |
$review_comment_db_result = $review_db_result->count() | |
? ORM::factory('Review_Comment') | |
->where('review_id', 'IN', $review_db_result->as_array(NULL, 'id')) | |
->order_by('id', 'DESC') | |
->find_all() | |
: []; | |
// Group comments by review | |
$indexed_comments = Arr::group_by_property('review_id', $review_comment_db_result); | |
// Format reviews and comments | |
$reviews = Arr::mapp(function($review) use ($indexed_comments) { | |
return $this->normalize_review($review, Arr::gett($indexed_comments, $review->id, [])); | |
}, $review_db_result); | |
// Find score for given result set | |
$total = array_sum(array_column($reviews, 'rating')); | |
$count = count($reviews); | |
$score = $count ? ($total / $count) : NULL; | |
// Return | |
$res = array_merge([ | |
'status' => 'success', | |
'results' => $reviews, | |
'meta' => [ | |
'score' => $score, | |
'count' => $count, | |
'locale' => ($this->locale ? $this->locale->id : NULL), | |
'cache' => [ | |
'fresh' => FALSE, | |
'no_cache' => $this->no_cache, | |
'cleared' => $this->cache_cleared, | |
'namespace' => [ | |
'key' => $this->cache_namespace_key, | |
'value' => $this->cache_namespace, | |
], | |
'key' => $this->cache_key, | |
'created_at' => date('c'), | |
], | |
], | |
], $pagination); | |
$this->cache($query)->set->__invoke($res); | |
$res['meta']['cache']['fresh'] = TRUE; | |
$this->return_data($res); | |
} | |
/** | |
* Normalizer for individual Model_Review instances | |
* | |
* @param Model_Review $review | |
* @param Iterable $comments | |
* | |
* @return array | |
* | |
* @author Arnar Yngvason | |
*/ | |
public function normalize_review($review, $comments) | |
{ | |
$review_locale = Arr::gett(Model_Locale::$all_locales, $review->locale_id); | |
$user_image = Helper_Images::get_image_object( | |
$review->user->get_front_image(), | |
$this->locale, | |
0, | |
$review->user->name, | |
FALSE | |
); | |
return [ | |
'id' => (int) $review->id, | |
'user' => (object) [ | |
'id' => (int) $review->user_id, | |
'name' => (string) $review->user->get_name(), | |
'image' => $user_image | |
], | |
'created_time' => (string) $review->created_time, | |
'rating' => (float) $review->rating, | |
'content' => (string) $review->content, | |
'locale_id' => ($review_locale ? (string) $review_locale->id : NULL), | |
'locale_name' => ($review_locale ? (string) $review_locale->name : NULL), | |
'comments' => Arr::mapp([$this, 'normalize_comment'], $comments), | |
]; | |
} | |
/** | |
* Normalizer for individual Model_Review_Comment instances | |
* | |
* @param Model_Review_Comment $comment | |
* | |
* @return array | |
* | |
* @author Arnar Yngvason | |
*/ | |
public function normalize_comment($comment) | |
{ | |
$user_image = Helper_Images::get_image_object( | |
$comment->user->get_front_image(), | |
$this->locale, | |
0, | |
$comment->user->name, | |
FALSE | |
); | |
return [ | |
'id' => (int) $comment->id, | |
'content' => (string) $comment->content, | |
'user' => [ | |
'id' => (int) $comment->user->id, | |
'name' => (string) $comment->user->get_name(), | |
'image' => $user_image, | |
], | |
]; | |
} | |
/** | |
* Handle creation of new reviews | |
* @note Fields are required unless otherwise specified | |
* | |
* Accepts POST fields: | |
* @param int $user_id ID of the user that submitted the review. | |
* @param float $rating Rating of the review min 0, max 5. | |
* @param string $content Optional, can be empty. Content of the review. | |
* @param string $locale_id Optional, Locale of the review, defaults to marketplace default. | |
* @param string $product_type Product type this review is for. | |
* @param int $product_id Product ID this review is for. | |
* @param int $order_id Optional, Marks given order reviewed, requires product type and id. | |
* @param string $review_key Optional, Required if order_id is given. Verification hash for submitting reviews for given order. | |
* @param string $url Optional, URL included in return for client redirection, defaults to site front url | |
* | |
* @return JSON Returns JSON object with operation status and optionally message | |
* | |
* @author Alex Makeev | |
*/ | |
public function action_post() | |
{ | |
$locale_id = $this->request->post('locale_id'); | |
$this->locale = Arr::gett(Model_Locale::$all_locales, $locale_id, Model_Locale::$default); | |
// Verify user that submitted the review | |
$user_id = $this->request->post('user_id'); | |
$user = $this->verify_user($user_id); | |
// Verify rating | |
$rating = $this->verify_rating($this->request->post('rating')); | |
// Verify product and type | |
$type = $this->request->post('product_type'); | |
$product_id = $this->request->post('product_id'); | |
$orm = $this->verify_product($type, $product_id); | |
// Check and verify order/booking | |
$order_id = $this->request->post('order_id'); | |
$review_key = $this->request->post('review_key'); | |
$this->verify_booking($type, $review_key, $order_id); | |
// Invalidate all cache for product | |
$this->invalidate_cache($product_type, $product_id); | |
// Creat ethe review and add it to the product | |
$review = ORM::factory('Review'); | |
$review->values([ | |
'user_id' => $user->id, | |
'rating' => $rating, | |
'content' => $this->request->post('content'), | |
'locale_id' => $this->locale->id, | |
'is_new' => TRUE | |
]); | |
$review->save(); | |
$review->reload(); | |
$orm->add('reviews', $review); | |
$this->set_sortorder($review, $orm, $type); | |
$url = $this->request->post('url'); | |
$this->return_data([ | |
'status' => 'success', | |
'url' => ($url ?: $orm->get_front_url()), | |
'id' => $review->id, | |
'index' => $review->sortorder | |
]); | |
} | |
/** | |
* Loads and verifies user | |
* | |
* @param int $user_id | |
* | |
* @return JSON|Model_User | |
* | |
* @author Alex makeev | |
*/ | |
public function verify_user($user_id) | |
{ | |
$user = ORM::factory('User', ['id' => $user_id, 'deleted' => 0]); | |
if ( ! $user->loaded()) | |
{ | |
$this->return_data([ | |
'status' => 'error', | |
'type' => 'user', | |
'message' => 'User not found' | |
]); | |
} | |
elseif ( ! $user->image->loaded()) | |
{ | |
$this->return_data([ | |
'status' => 'error', | |
'type' => 'user_image', | |
'message' => 'Missing image' | |
]); | |
} | |
return $user; | |
} | |
/** | |
* Verrifies rating | |
* | |
* @param float $rating | |
* | |
* @return JSON|float | |
* | |
* @author Alex Makeev | |
*/ | |
public function verify_rating($rating) | |
{ | |
if ( ! is_numeric($rating) OR 0 > (float) $rating OR 5 < (float) $rating) | |
{ | |
$this->return_data([ | |
'status' => 'error', | |
'type' => 'rating', | |
'message' => 'Rating must be numeric and between 0 and 5' | |
]); | |
} | |
return (float) $rating; | |
} | |
/** | |
* Verifies and loads reviewed product | |
* | |
* @param string $type | |
* @param int $id | |
* | |
* @return JSON|ORM | |
* | |
* @author Alex Makeev | |
*/ | |
public function verify_product($type, $id) | |
{ | |
// Verify type | |
if ( ! array_key_exists($type, $this->_post_types)) | |
{ | |
$this->return_data([ | |
'status' => 'error', | |
'type' => 'product_type', | |
'message' => 'Unsupported type' | |
]); | |
// This return is for the unit test | |
return; | |
} | |
// Verify reviewed orm exists | |
$orm = ORM::factory($type, $id); | |
if ( ! $orm->loaded()) | |
{ | |
$this->return_data([ | |
'status' => 'error', | |
'type' => 'product', | |
'message' => 'Reviewed item not found' | |
]); | |
} | |
return $orm; | |
} | |
/** | |
* Verifeis and updates booking | |
* | |
* @param string $type | |
* @param string $key | |
* @param int $id | |
* | |
* @param NULL|ORM | |
* | |
* @author Alex Makeev | |
*/ | |
public function verify_booking($type, $key, $id) | |
{ | |
if ($id AND $key AND Arr::gett($this->_post_types, $type)) | |
{ | |
$booking = ORM::factory($this->_post_types[$type], $id); | |
if ($booking->loaded() AND sha1($booking->id . $booking->price) === $key) | |
{ | |
$booking->reviewed = 1; | |
$booking->save(); | |
return $booking; | |
} | |
} | |
return NULL; | |
} | |
/** | |
* Updates sortorder of given review, and shofts order of other reviews | |
* @note sortorder field of the given review must be empty and not modified | |
* | |
* @param Model_Review | |
* @param ORM | |
* @param string | |
* | |
* @return NULL | |
* | |
* @author Alex Makeev | |
*/ | |
public function set_sortorder( & $review, & $orm, $type) | |
{ | |
if ($review->sortorder OR $review->changed('sortorder')) | |
{ | |
return; | |
} | |
// Prepare selection data | |
$select_type = ($type === 'establishment') ? 'car' : $type; | |
$connector = Arr::gett($this->_types, $select_type); | |
$column = Arr::gett($this->_columns, $select_type, "{$select_type}_id"); | |
// Fetch list of reviews for this product | |
$review_ids = DB::select('review_id') | |
->from([$connector, 'conn']) | |
->join('reviews') | |
->on('reviews.id', '=', 'conn.review_id') | |
->where($column, '=', $orm->id) | |
->where('locale_id', '=', $this->locale->id) | |
->execute() | |
->as_array(NULL, 'review_id'); | |
// Prepare the update query | |
$update_query = DB::update('reviews') | |
->set(['sortorder' => DB::expr('sortorder + 1')]) | |
->where('id', '!=', $review->id) | |
->where('id', 'IN', $review_ids); | |
if (count($review_ids)) | |
{ | |
if ( (float) $review->rating < 4) | |
{ | |
// Figure out where to put the review | |
$sortorder = (int) DB::select([DB::expr('COUNT(id)'), 'cc']) | |
->from('reviews') | |
->where('rating', '>', $review->rating) | |
->where('id', '!=', $review->id) | |
->where('id', 'IN', $review_ids) | |
->execute() | |
->get('cc') + 1; | |
$update_query->where('sortorder', '>=', $sortorder); | |
} | |
// Update the sortorder of other reviews | |
$update_query->execute(); | |
} | |
// Update the review | |
$review->sortorder = ( ! empty($sortorder)) ? $sortorder : 1; | |
$review->save(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
// If we hadn't gone with `redis` I would have implemented something like this | |
// for abstracing away the "complicated" stuff... | |
class NsCache() { | |
// ... | |
/** | |
* Get cache from namespace | |
* | |
* @param string|array<string> $namespace | |
* @param string $key | |
* @param mixed $default | |
* | |
* @return mixed | |
*/ | |
public static function get($namespace, $key, $default) { | |
// ... | |
} | |
/** | |
* Set cache in namespace | |
* | |
* @param string|array<string> $namespace | |
* @param string $key | |
* @param mixed $value | |
* | |
* @return void | |
*/ | |
public static function set($namespace, $key, $value) { | |
// ... | |
} | |
/** | |
* Drop cache in namespace(s) | |
* | |
* @param string|array<string> $namespace | |
* | |
* @return void | |
*/ | |
public static function drop_namespace($namespace) { | |
// ... | |
} | |
// ... | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment