Skip to content

Instantly share code, notes, and snippets.

@demux
Last active June 8, 2017 15:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save demux/fc8aa0ce3883d7c2cda31578bf07f3df to your computer and use it in GitHub Desktop.
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.
<?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();
}
}
<?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