diff --git a/controllers/BaseApiController.php b/controllers/BaseApiController.php index f6755d24..37171a1e 100644 --- a/controllers/BaseApiController.php +++ b/controllers/BaseApiController.php @@ -2,10 +2,16 @@ namespace Grocy\Controllers; +use LessQL\Result; + class BaseApiController extends BaseController { protected $OpenApiSpec = null; + const PATTERN_FIELD = '[A-Za-z_][A-Za-z0-9_]+'; + const PATTERN_OPERATOR = '!?(=|~|<|>|(>=)|(<=))'; + const PATTERN_VALUE = '[A-Za-z_0-9.]+'; + public function __construct(\DI\Container $container) { parent::__construct($container); @@ -29,6 +35,68 @@ class BaseApiController extends BaseController ]); } + public function FilteredApiResponse(\Psr\Http\Message\ResponseInterface $response, Result $data, array $query) + { + $data = $this->queryData($data, $query); + return $this->ApiResponse($response, $data); + } + + protected function queryData(Result $data, array $query) + { + if (isset($query['query'])) + $data = $this->filter($data, $query['query']); + if (isset($query['limit'])) + $data = $data->limit(intval($query['limit']), intval($query['offset'] ?? 0)); + if (isset($query['order'])) + $data = $data->orderBy($query['order']); + return $data; + } + + protected function filter(Result $data, array $query): Result + { + foreach ($query as $q) { + $matches = array(); + preg_match('/(?P' . self::PATTERN_FIELD . ')' + . '(?P' . self::PATTERN_OPERATOR . ')' + . '(?P' . self::PATTERN_VALUE . ')/', + $q, $matches + ); + error_log(var_export($matches, true)); + switch ($matches['op']) { + case '=': + $data = $data->where($matches['field'], $matches['value']); + break; + case '!=': + $data = $data->whereNot($matches['field'], $matches['value']); + break; + case '~': + $data = $data->where($matches['field'] . ' LIKE ?', '%' . $matches['value'] . '%'); + break; + case '!~': + $data = $data->where($matches['field'] . ' NOT LIKE ?', '%' . $matches['value'] . '%'); + break; + case '!>=': + case '<': + $data = $data->where($matches['field'] . ' < ?', $matches['value']); + break; + case '!<=': + case '>': + $data = $data->where($matches['field'] . ' > ?', $matches['value']); + break; + case '!<': + case '>=': + $data = $data->where($matches['field'] . ' >= ?', $matches['value']); + break; + case '!>': + case '<=': + $data = $data->where($matches['field'] . ' <= ?', $matches['value']); + break; + + } + } + return $data; + } + protected function getOpenApispec() { if ($this->OpenApiSpec == null) diff --git a/controllers/BatteriesApiController.php b/controllers/BatteriesApiController.php index 651c9100..57f401d7 100644 --- a/controllers/BatteriesApiController.php +++ b/controllers/BatteriesApiController.php @@ -21,7 +21,7 @@ class BatteriesApiController extends BaseApiController public function Current(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { - return $this->ApiResponse($response, $this->getBatteriesService()->GetCurrent()); + return $this->FilteredApiResponse($response, $this->getBatteriesService()->GetCurrent(), $request->getQueryParams()); } public function TrackChargeCycle(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) diff --git a/controllers/ChoresApiController.php b/controllers/ChoresApiController.php index 71345367..10673514 100644 --- a/controllers/ChoresApiController.php +++ b/controllers/ChoresApiController.php @@ -58,7 +58,7 @@ class ChoresApiController extends BaseApiController public function Current(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { - return $this->ApiResponse($response, $this->getChoresService()->GetCurrent()); + return $this->FilteredApiResponse($response, $this->getChoresService()->GetCurrent(), $request->getQueryParams()); } public function TrackChoreExecution(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) diff --git a/controllers/GenericEntityApiController.php b/controllers/GenericEntityApiController.php index 97be51fc..f3b72afa 100644 --- a/controllers/GenericEntityApiController.php +++ b/controllers/GenericEntityApiController.php @@ -3,6 +3,7 @@ namespace Grocy\Controllers; use Grocy\Controllers\Users\User; +use Slim\Exception\HttpBadRequestException; class GenericEntityApiController extends BaseApiController { @@ -178,12 +179,13 @@ class GenericEntityApiController extends BaseApiController { try { - return $this->ApiResponse($response, $this->getDatabase()->{$args['entity']} - ()->where('name LIKE ?', '%' . $args['searchString'] . '%')); + return $this->FilteredApiResponse($response, $this->getDatabase()->{$args['entity']} + ()->where('name LIKE ?', '%' . $args['searchString'] . '%'), $request->getQueryParams()); } catch (\PDOException $ex) { - return $this->GenericErrorResponse($response, 'The given entity has no field "name"'); + throw new HttpBadRequestException($request, $ex->getMessage(), $ex); + //return $this->GenericErrorResponse($response, 'The given entity has no field "name"', $ex); } } diff --git a/controllers/RecipesApiController.php b/controllers/RecipesApiController.php index 1825f3a6..804e2791 100644 --- a/controllers/RecipesApiController.php +++ b/controllers/RecipesApiController.php @@ -44,7 +44,7 @@ class RecipesApiController extends BaseApiController { if (!isset($args['recipeId'])) { - return $this->ApiResponse($response, $this->getRecipesService()->GetRecipesResolved()); + return $this->FilteredApiResponse($response, $this->getRecipesService()->GetRecipesResolved(), $request->getQueryParams()); } $recipeResolved = FindObjectInArrayByPropertyValue($this->getRecipesService()->GetRecipesResolved(), 'recipe_id', $args['recipeId']); diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index b693de69..8623a046 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -552,12 +552,12 @@ class StockApiController extends BaseApiController $allowSubproductSubstitution = true; } - return $this->ApiResponse($response, $this->getStockService()->GetProductStockEntries($args['productId'], false, $allowSubproductSubstitution)); + return $this->FilteredApiResponse($response, $this->getStockService()->GetProductStockEntries($args['productId'], false, $allowSubproductSubstitution, false), $request->getQueryParams()); } public function ProductStockLocations(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { - return $this->ApiResponse($response, $this->getStockService()->GetProductStockLocations($args['productId'])); + return $this->FilteredApiResponse($response, $this->getStockService()->GetProductStockLocations($args['productId']), $request->getQueryParams()); } public function RemoveProductFromShoppingList(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) diff --git a/controllers/TasksApiController.php b/controllers/TasksApiController.php index 6d0d6f39..e57cf8f3 100644 --- a/controllers/TasksApiController.php +++ b/controllers/TasksApiController.php @@ -8,7 +8,7 @@ class TasksApiController extends BaseApiController { public function Current(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { - return $this->ApiResponse($response, $this->getTasksService()->GetCurrent()); + return $this->FilteredApiResponse($response, $this->getTasksService()->GetCurrent(), $request->getQueryParams()); } public function MarkTaskAsCompleted(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) diff --git a/controllers/UsersApiController.php b/controllers/UsersApiController.php index c3837261..e28ac4f1 100644 --- a/controllers/UsersApiController.php +++ b/controllers/UsersApiController.php @@ -123,7 +123,7 @@ class UsersApiController extends BaseApiController User::checkPermission($request, User::PERMISSION_USERS_READ); try { - return $this->ApiResponse($response, $this->getUsersService()->GetUsersAsDto()); + return $this->FilteredApiResponse($response, $this->getUsersService()->GetUsersAsDto(), $request->getQueryParams()); } catch (\Exception $ex) { diff --git a/migrations/0114.sql b/migrations/0114.sql new file mode 100644 index 00000000..b4b19351 --- /dev/null +++ b/migrations/0114.sql @@ -0,0 +1,92 @@ +CREATE VIEW users_dto +AS +SELECT + id, + username, + first_name, + last_name, + row_created_timestamp, + (CASE + WHEN IFNULL(first_name, '') = '' AND IFNULL(last_name, '') != '' THEN last_name + WHEN IFNULL(last_name, '') = '' AND IFNULL(first_name, '') != '' THEN first_name + WHEN IFNULL(last_name, '') != '' AND IFNULL(first_name, '') != '' THEN first_name + ' ' + last_name + ELSE username + END + ) AS display_name +FROM users; + +DROP VIEW chores_current; +CREATE VIEW chores_current +AS +SELECT + x.chore_id AS id, -- Dummy, LessQL needs an id column + x.chore_id, + x.chore_name, + x.last_tracked_time, + CASE WHEN x.rollover = 1 AND DATETIME('now', 'localtime') > x.next_estimated_execution_time THEN + DATETIME(STRFTIME('%Y-%m-%d', DATETIME('now', 'localtime')) || ' ' || STRFTIME('%H:%M:%S', x.next_estimated_execution_time)) + ELSE + x.next_estimated_execution_time + END AS next_estimated_execution_time, + x.track_date_only, + x.next_execution_assigned_to_user_id +FROM ( + +SELECT + h.id AS chore_id, + h.name AS chore_name, + MAX(l.tracked_time) AS last_tracked_time, + CASE h.period_type + WHEN 'manually' THEN '2999-12-31 23:59:59' + WHEN 'dynamic-regular' THEN DATETIME(MAX(l.tracked_time), '+' || CAST(h.period_days AS TEXT) || ' day') + WHEN 'daily' THEN DATETIME(IFNULL(MAX(l.tracked_time), DATETIME('now', 'localtime')), '+' || CAST(h.period_interval AS TEXT) || ' day') + WHEN 'weekly' THEN ( + SELECT next + FROM ( + SELECT 'sunday' AS day, DATETIME(COALESCE((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), DATETIME('now', 'localtime')), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 0') AS next + UNION + SELECT 'monday' AS day, DATETIME(COALESCE((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), DATETIME('now', 'localtime')), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 1') AS next + UNION + SELECT 'tuesday' AS day, DATETIME(COALESCE((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), DATETIME('now', 'localtime')), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 2') AS next + UNION + SELECT 'wednesday' AS day, DATETIME(COALESCE((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), DATETIME('now', 'localtime')), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 3') AS next + UNION + SELECT 'thursday' AS day, DATETIME(COALESCE((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), DATETIME('now', 'localtime')), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 4') AS next + UNION + SELECT 'friday' AS day, DATETIME(COALESCE((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), DATETIME('now', 'localtime')), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 5') AS next + UNION + SELECT 'saturday' AS day, DATETIME(COALESCE((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), DATETIME('now', 'localtime')), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 6') AS next + ) + WHERE INSTR(period_config, day) > 0 + ORDER BY next + LIMIT 1 + ) + WHEN 'monthly' THEN DATETIME(IFNULL(MAX(l.tracked_time), DATETIME('now', 'localtime')), '+' || CAST(h.period_interval AS TEXT) || ' month', 'start of month', '+' || CAST(h.period_days - 1 AS TEXT) || ' day') + WHEN 'yearly' THEN DATETIME(IFNULL(MAX(l.tracked_time), DATETIME('now', 'localtime')), '+' || CAST(h.period_interval AS TEXT) || ' years') + END AS next_estimated_execution_time, + h.track_date_only, + h.rollover, + h.next_execution_assigned_to_user_id +FROM chores h +LEFT JOIN chores_log l + ON h.id = l.chore_id + AND l.undone = 0 +GROUP BY h.id, h.name, h.period_days + +) x; + +DROP VIEW batteries_current; +CREATE VIEW batteries_current +AS +SELECT + b.id, -- Dummy, LessQL needs an id column + b.id AS battery_id, + MAX(l.tracked_time) AS last_tracked_time, + CASE WHEN b.charge_interval_days = 0 + THEN '2999-12-31 23:59:59' + ELSE datetime(MAX(l.tracked_time), '+' || CAST(b.charge_interval_days AS TEXT) || ' day') + END AS next_estimated_charge_time +FROM batteries b +LEFT JOIN battery_charge_cycles l + ON b.id = l.battery_id +GROUP BY b.id, b.charge_interval_days; diff --git a/routes.php b/routes.php index 63b01aa9..4be16bf8 100644 --- a/routes.php +++ b/routes.php @@ -153,7 +153,7 @@ $app->group('/api', function(RouteCollectorProxy $group) // Generic entity interaction $group->get('/objects/{entity}', '\Grocy\Controllers\GenericEntityApiController:GetObjects'); $group->get('/objects/{entity}/{objectId}', '\Grocy\Controllers\GenericEntityApiController:GetObject'); - $group->get('/objects/{entity}/search/{searchString}', '\Grocy\Controllers\GenericEntityApiController:SearchObjects'); + $group->get('/objects/{entity}/search/{searchString:.*}', '\Grocy\Controllers\GenericEntityApiController:SearchObjects'); $group->post('/objects/{entity}', '\Grocy\Controllers\GenericEntityApiController:AddObject'); $group->put('/objects/{entity}/{objectId}', '\Grocy\Controllers\GenericEntityApiController:EditObject'); $group->delete('/objects/{entity}/{objectId}', '\Grocy\Controllers\GenericEntityApiController:DeleteObject'); diff --git a/services/BatteriesService.php b/services/BatteriesService.php index 7fc4276e..62ee2042 100644 --- a/services/BatteriesService.php +++ b/services/BatteriesService.php @@ -26,8 +26,7 @@ class BatteriesService extends BaseService public function GetCurrent() { - $sql = 'SELECT * from batteries_current'; - return $this->getDatabaseService()->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); + return $this->getDatabase()->batteries_current(); } public function TrackChargeCycle(int $batteryId, string $trackedTime) diff --git a/services/ChoresService.php b/services/ChoresService.php index 6f161293..ddd485e3 100644 --- a/services/ChoresService.php +++ b/services/ChoresService.php @@ -152,8 +152,7 @@ class ChoresService extends BaseService public function GetCurrent() { - $sql = 'SELECT chores_current.*, chores.name AS chore_name from chores_current join chores on chores_current.chore_id = chores.id'; - return $this->getDatabaseService()->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); + return $this->getDatabase()->chores_current(); } public function TrackChore(int $choreId, string $trackedTime, $doneBy = GROCY_USER_ID) diff --git a/services/RecipesService.php b/services/RecipesService.php index 12f74cbf..3e188c2f 100644 --- a/services/RecipesService.php +++ b/services/RecipesService.php @@ -4,6 +4,8 @@ namespace Grocy\Services; #use \Grocy\Services\StockService; +use LessQL\Result; + class RecipesService extends BaseService { const RECIPE_TYPE_MEALPLAN_DAY = 'mealplan-day'; @@ -82,10 +84,9 @@ class RecipesService extends BaseService return $this->getDataBaseService()->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); } - public function GetRecipesResolved() + public function GetRecipesResolved(): Result { - $sql = 'SELECT * FROM recipes_resolved'; - return $this->getDataBaseService()->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); + return $this->getDatabase()->recipes_resolved(); } public function __construct() diff --git a/services/StockService.php b/services/StockService.php index 6596638a..89989e05 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -603,7 +603,7 @@ class StockService extends BaseService return $returnData; } - public function GetProductStockEntries($productId, $excludeOpened = false, $allowSubproductSubstitution = false) + public function GetProductStockEntries($productId, $excludeOpened = false, $allowSubproductSubstitution = false, $ordered = true) { // In order of next use: // First expiring first, then first in first out @@ -621,8 +621,10 @@ class StockService extends BaseService { $sqlWhereAndOpen = 'AND open = 0'; } - - return $this->getDatabase()->stock()->where($sqlWhereProductId . ' ' . $sqlWhereAndOpen, $productId)->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC')->fetchAll(); + $result = $this->getDatabase()->stock()->where($sqlWhereProductId . ' ' . $sqlWhereAndOpen, $productId); + if ($ordered) + return $result->orderBy('best_before_date', 'ASC')->orderBy('purchased_date', 'ASC'); + return $result; } public function GetProductStockEntriesForLocation($productId, $locationId, $excludeOpened = false, $allowSubproductSubstitution = false) @@ -633,7 +635,7 @@ class StockService extends BaseService public function GetProductStockLocations($productId) { - return $this->getDatabase()->stock_current_locations()->where('product_id', $productId)->fetchAll(); + return $this->getDatabase()->stock_current_locations()->where('product_id', $productId); } public function GetStockEntry($entryId) diff --git a/services/TasksService.php b/services/TasksService.php index 2a4f870f..bb95281b 100644 --- a/services/TasksService.php +++ b/services/TasksService.php @@ -4,10 +4,9 @@ namespace Grocy\Services; class TasksService extends BaseService { - public function GetCurrent() + public function GetCurrent(): \LessQL\Result { - $sql = 'SELECT * from tasks_current'; - return $this->getDatabaseService()->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ); + return $this->getDatabase()->tasks_current(); } public function MarkTaskAsCompleted($taskId, $doneTime) diff --git a/services/UsersService.php b/services/UsersService.php index 407088d5..9da8bb89 100644 --- a/services/UsersService.php +++ b/services/UsersService.php @@ -81,19 +81,9 @@ class UsersService extends BaseService return array_merge($GROCY_DEFAULT_USER_SETTINGS, $settings); } - public function GetUsersAsDto() + public function GetUsersAsDto(): \LessQL\Result { - $users = $this->getDatabase()->users(); - $returnUsers = []; - - foreach ($users as $user) - { - unset($user->password); - $user->display_name = GetUserDisplayName($user); - $returnUsers[] = $user; - } - - return $returnUsers; + return $this->getDatabase()->users_dto(); } public function SetUserSetting($userId, $settingKey, $settingValue) diff --git a/views/batteriesoverview.blade.php b/views/batteriesoverview.blade.php index ed2d5fd6..2f021700 100644 --- a/views/batteriesoverview.blade.php +++ b/views/batteriesoverview.blade.php @@ -80,9 +80,7 @@ @foreach($current as $currentBatteryEntry) @@ -149,8 +147,7 @@ , $currentBatteryEntry->battery_id)->charge_interval_days > 0 && $currentBatteryEntry->next_estimated_charge_time < date('Y-m-d H:i:s', - strtotime("+$nextXDays - days"))) + strtotime("+$nextXDays days"))) duesoon @endif diff --git a/views/choresoverview.blade.php b/views/choresoverview.blade.php index 7ebf2a0c..7063499d 100644 --- a/views/choresoverview.blade.php +++ b/views/choresoverview.blade.php @@ -110,9 +110,7 @@ @foreach($currentChores as $curentChoreEntry) @@ -191,8 +189,7 @@ , $curentChoreEntry->chore_id)->period_type !== \Grocy\Services\ChoresService::CHORE_PERIOD_TYPE_MANUALLY && $curentChoreEntry->next_estimated_execution_time < date('Y-m-d H:i:s', - strtotime("+$nextXDays - days"))) + strtotime("+$nextXDays days"))) duesoon @endif diff --git a/views/stockentries.blade.php b/views/stockentries.blade.php index 97b475ee..d7e0bf32 100644 --- a/views/stockentries.blade.php +++ b/views/stockentries.blade.php @@ -54,9 +54,7 @@ @foreach($stockEntries as $stockEntry) amount > 0) table-warning @endif"> diff --git a/views/stockoverview.blade.php b/views/stockoverview.blade.php index 7aa251d1..78c579a4 100644 --- a/views/stockoverview.blade.php +++ b/views/stockoverview.blade.php @@ -142,9 +142,7 @@ @foreach($currentStock as $currentStockEntry) amount > 0) table-warning @elseif ($currentStockEntry->product_missing) table-info @endif"> @@ -324,8 +322,7 @@ && $currentStockEntry->amount > 0) expired @elseif($currentStockEntry->best_before_date < date('Y-m-d 23:59:59', - strtotime("+$nextXDays - days")) + strtotime("+$nextXDays days")) && $currentStockEntry->amount > 0) expiring @endif @if($currentStockEntry->product_missing) belowminstockamount @endif diff --git a/views/tasks.blade.php b/views/tasks.blade.php index 89ebb583..fc079c66 100644 --- a/views/tasks.blade.php +++ b/views/tasks.blade.php @@ -156,8 +156,7 @@ @if($task->done == 1) text-muted @endif @if(!empty($task->due_date) && $task->due_date < date('Y-m-d')) overdue @elseif(!empty($task->due_date) && $task->due_date < date('Y-m-d', - strtotime("+$nextXDays - days"))) + strtotime("+$nextXDays days"))) duesoon @endif