Commit 88fc0673 authored by François Agneray's avatar François Agneray
Browse files

Refactoring search action and search package

parent 2fdcb28c
Pipeline #5071 passed with stages
in 2 minutes and 27 seconds
......@@ -104,14 +104,6 @@ $container->set('App\Action\GroupAction', function (ContainerInterface $c) {
return new App\Action\GroupAction($c->get('em'));
});
$container->set('App\Action\FamilyListAction', function (ContainerInterface $c) {
return new App\Action\FamilyListAction($c->get('em'));
});
$container->set('App\Action\FamilyAction', function (ContainerInterface $c) {
return new App\Action\FamilyAction($c->get('em'));
});
$container->set('App\Action\InstanceListAction', function (ContainerInterface $c) {
return new App\Action\InstanceListAction($c->get('em'));
});
......@@ -181,10 +173,20 @@ $container->set('App\Action\AttributeDistinctAction', function (ContainerInterfa
});
$container->set('App\Action\SearchAction', function (ContainerInterface $c) {
$anisQueryBuilder = (new App\Search\Query\AnisQueryBuilder())
->addQueryPart(new App\Search\Query\From())
->addQueryPart(new App\Search\Query\Count())
->addQueryPart(new App\Search\Query\Select())
->addQueryPart(new App\Search\Query\ConeSearch())
->addQueryPart(new App\Search\Query\Where(new App\Search\Query\Operator\OperatorFactory()))
->addQueryPart(new App\Search\Query\Order())
->addQueryPart(new App\Search\Query\Limit());
return new App\Action\SearchAction(
$c->get('em'),
new App\Search\DBALConnectionFactory(),
new App\Search\Operator\OperatorFactory(),
$anisQueryBuilder,
new App\Search\Response\ResponseFactory(),
$c->get(SETTINGS)['token']
);
});
......
......@@ -12,19 +12,15 @@ declare(strict_types=1);
namespace App\Action;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpNotFoundException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Driver\Statement;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use App\Search\DBALConnectionFactory;
use App\Search\Operator\OperatorFactory;
use App\Search\Query\AnisQueryBuilder;
use App\Search\Response\IResponseFactory;
use App\Search\SearchException;
use App\Entity\Dataset;
use App\Entity\Attribute;
/**
* Search action
......@@ -35,7 +31,8 @@ use App\Entity\Attribute;
final class SearchAction extends AbstractAction
{
private $connectionFactory;
private $operatorFactory;
private $anisQueryBuilder;
private $responseFactory;
/**
* Contains settings to handle Json Web Token
......@@ -54,12 +51,14 @@ final class SearchAction extends AbstractAction
public function __construct(
EntityManagerInterface $em,
DBALConnectionFactory $connectionFactory,
OperatorFactory $operatorFactory,
AnisQueryBuilder $anisQueryBuilder,
IResponseFactory $responseFactory,
array $settings
) {
parent::__construct($em);
$this->connectionFactory = $connectionFactory;
$this->operatorFactory = $operatorFactory;
$this->anisQueryBuilder = $anisQueryBuilder;
$this->responseFactory = $responseFactory;
$this->settings = $settings;
}
......@@ -94,11 +93,6 @@ final class SearchAction extends AbstractAction
$this->verifyDatasetAuthorization($request, $dataset->getName(), $this->settings['admin_role']);
}
// Create query builder with from clause using dataset information
$connection = $this->connectionFactory->create($dataset->getProject()->getDatabase());
$queryBuilder = $connection->createQueryBuilder();
$queryBuilder->from($dataset->getTableRef());
$queryParams = $request->getQueryParams();
// The parameter "a" is mandatory
......@@ -110,44 +104,17 @@ final class SearchAction extends AbstractAction
}
try {
// The parameter a represents the SQL select clause
if ($queryParams['a'] === 'count') {
$attributes = array();
$queryBuilder->select('COUNT(*) as nb');
} else {
$attributes = $this->select($queryBuilder, $dataset, explode(';', $queryParams['a']));
}
// The special parameter cs is not mandatory and represents the Cone Search
if (array_key_exists('cs', $queryParams)) {
$this->coneSearch($queryBuilder, $dataset, $queryParams['cs']);
}
// The parameter c is not mandatory and represents the SQL where clause
if (array_key_exists('c', $queryParams)) {
$this->where($queryBuilder, $dataset, explode(';', $queryParams['c']));
}
// Configure the Anis Query Builder
$connection = $this->connectionFactory->create($dataset->getProject()->getDatabase());
$this->anisQueryBuilder->setDoctrineQueryBuilder($connection->createQueryBuilder());
// The parameter o is not mandatory and represents the SQL order clause
if (array_key_exists('o', $queryParams)) {
$this->order($queryBuilder, $dataset, explode(';', $queryParams['o']));
}
// The parameter p is not mandatory and represents the SQL limit clause
if (array_key_exists('p', $queryParams)) {
$this->limit($queryBuilder, $queryParams['p']);
}
// Build the SQL request
$this->anisQueryBuilder->build($dataset, $queryParams);
// The parameter f is not mandatory and represents the output format
// By default the format is JSON
if (array_key_exists('f', $queryParams)) {
$format = $queryParams['f'];
} else {
$format = 'json';
}
// Create the response according to the format
return $this->getFormattedResponse($response, $queryBuilder, $attributes, $format);
$format = (array_key_exists('f', $queryParams)) ? $queryParams['f'] : 'json';
return $this->responseFactory->create($format)->getResponse($response, $this->anisQueryBuilder);
} catch (SearchException $e) {
throw new HttpBadRequestException(
$request,
......@@ -155,248 +122,4 @@ final class SearchAction extends AbstractAction
);
}
}
/**
* Adds the select clause to the request
*
* @param QueryBuilder $queryBuilder Represents the query being built
* @param Dataset $dataset Represents the requested dataset
* @param string[] $listOfIds The substring of the url (parameter a)
*/
private function select(QueryBuilder $queryBuilder, Dataset $dataset, array $listOfIds): array
{
$columns = array();
$attributes = array();
foreach ($listOfIds as $id) {
$attribute = $this->getAttribute($dataset, (int) $id);
$columns[] = $attribute->getTableName() . '.' . $attribute->getName() . ' as ' . $attribute->getLabel();
$attributes[] = $attribute;
}
$queryBuilder->select($columns);
return $attributes;
}
/**
* Adds the cone search clause to the request
*
* @param QueryBuilder $queryBuilder Represents the query being built
* @param Dataset $dataset Represents the requested dataset
* @param string $param The substring of the url (parameter cs)
*/
private function coneSearch(QueryBuilder $queryBuilder, Dataset $dataset, string $param): void
{
if (count(explode(':', $param)) < 3) {
throw SearchException::badNumberOfParamsForConeSearch();
}
list($ra, $dec, $radius) = explode(':', $param);
$ra = floatval($ra);
$dec = floatval($dec);
$radius = floatval($radius);
$coneSearchConfig = $dataset->getConfig()['cone_search'];
if ($coneSearchConfig['enabled'] !== true) {
throw SearchException::coneSearchUnavailable();
}
$attributeRa = $this->getAttribute($dataset, $coneSearchConfig['column_ra']);
$attributeDec = $this->getAttribute($dataset, $coneSearchConfig['column_dec']);
$columnRa = $dataset->getTableRef() . '.' . $attributeRa->getName();
$columnDec = $dataset->getTableRef() . '.' . $attributeDec->getName();
$cdcl2 = pow(cos($dec * (M_PI * 2) / 360), 2);
if ($radius == 0) {
$radius = 1 / 1000000;
}
$radius2 = pow($radius, 2);
$raddeg = $radius / 3600;
$decmin = $dec - $raddeg;
$decmax = $dec + $raddeg;
if ($decmin < -90 || $decmax > 90) {
$ramin = 0;
$ramax = 360;
} else {
$ra_corrected_radius = $raddeg / cos(deg2rad(abs($dec) + $raddeg));
$ramin = $ra - $ra_corrected_radius;
$ramax = $ra + $ra_corrected_radius;
}
$coneSearchValue = '(' . $cdcl2 . ' * (' . $ra . ' - ' . $columnRa . ') * (' . $ra . ' - ' . $columnRa . '))';
$coneSearchValue .= ' + ((' . $dec . ' - ' . $columnDec . ') * (' . $dec . ' - ' . $columnDec . '))';
$coneSearchCriterion = $queryBuilder->expr()->lte($coneSearchValue, $radius2);
$raCriterion = (new CompositeExpression(CompositeExpression::TYPE_AND, [
$queryBuilder->expr()->gte($columnRa, $ramin),
$queryBuilder->expr()->lte($columnRa, $ramax)
]));
$decCriterion = (new CompositeExpression(CompositeExpression::TYPE_AND, [
$queryBuilder->expr()->gte($columnDec, $decmin),
$queryBuilder->expr()->lte($columnDec, $decmax)
]));
$queryBuilder->where(new CompositeExpression(CompositeExpression::TYPE_AND, [
$coneSearchCriterion,
$raCriterion,
$decCriterion
]));
}
/**
* Adds the where clause to the request
*
* @param QueryBuilder $queryBuilder Represents the query being built
* @param Dataset $dataset Represents the requested dataset
* @param string[] $criteria The substring of the url (parameter c)
*/
private function where(QueryBuilder $queryBuilder, Dataset $dataset, array $criteria): void
{
$expressions = array();
foreach ($criteria as $criterion) {
$params = $params = explode('::', $criterion);
$attribute = $this->getAttribute($dataset, (int) $params[0]);
$column = $attribute->getTableName() . '.' . $attribute->getName();
$columnType = $attribute->getType();
if (array_key_exists(2, $params)) {
$values = explode('|', $params[2]);
} else {
$values = array();
}
$operator = $this->operatorFactory->create(
$params[1],
$queryBuilder->expr(),
$column,
$columnType,
$values
);
$expressions[] = $operator->getExpression();
}
$queryBuilder->andWhere(new CompositeExpression(CompositeExpression::TYPE_AND, $expressions));
}
/**
* Adds the order clause to the request
*
* @param QueryBuilder $queryBuilder Represents the query being built
* @param Dataset $dataset Represents the requested dataset
* @param string[] $orders The substring of the url (parameter o)
*/
private function order(QueryBuilder $queryBuilder, Dataset $dataset, array $orders): void
{
foreach ($orders as $order) {
$o = explode(':', $order);
if (count($o) != 2) {
throw SearchException::badNumberOfParamsForOrder();
}
$attribute = $this->getAttribute($dataset, (int) $o[0]);
if ($o[1] === 'a') {
$aord = 'ASC';
} elseif ($o[1] === 'd') {
$aord = 'DESC';
} else {
throw SearchException::typeOfOrderDoesNotExist($o[1]);
}
$queryBuilder->orderBy($attribute->getTableName() . '.' . $attribute->getName(), $aord);
}
}
/**
* Adds the limit clause to the request
*
* @param QueryBuilder $queryBuilder Represents the query being built
* @param string $param The substring of the url (parameter p)
*/
private function limit(QueryBuilder $queryBuilder, string $param): void
{
$p = explode(':', $param);
if (count($p) != 2) {
throw SearchException::badNumberOfParamsForLimit();
}
$limit = $p[0];
$offset = ($p[1] - 1) * $limit;
$queryBuilder
->setFirstResult($offset)
->setMaxResults($limit);
}
/**
* Returns the Attribute object of a dataset based on its ID (primary key)
*
* @throws SearchException Attribute with ID not found into the selected dataset
* @return Attribute Returns the attribute found
*/
private function getAttribute(Dataset $dataset, int $id): Attribute
{
$attributes = $dataset->getAttributes();
foreach ($attributes as $attribute) {
if ($attribute->getId() === $id) {
return $attribute;
}
}
throw SearchException::attributeNotFound($id, $dataset->getLabel());
}
private function getFormattedResponse(
Response $response,
QueryBuilder $queryBuilder,
array $attributes,
string $format
): Response {
// First of all execute the SQL query
$stmt = $queryBuilder->execute();
// Build and write the payload according to the format
switch ($format) {
case 'json':
$payload = json_encode($this->decodeNestedJson($stmt, $attributes), JSON_UNESCAPED_SLASHES);
$response->getBody()->write($payload);
return $response;
case 'csv':
$payload = $this->transformArrayToCsv($stmt, $attributes, ',');
$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'text/csv');
case 'ascii':
$payload = $this->transformArrayToCsv($stmt, $attributes, ' ');
$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'text/plain');
default:
throw SearchException::typeOfFormatDoesNotExist($format);
}
}
private function decodeNestedJson(Statement $stmt, array $attributes): array
{
$rows = array();
$jsonAttributes = $this->getAttributesOfTypeJson($attributes);
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
foreach ($row as $key => $column) {
if (array_search($key, $jsonAttributes) !== false) {
if (!is_null($column)) {
$row[$key] = json_decode($column, true);
}
}
}
$rows[] = $row;
}
return $rows;
}
private function getAttributesOfTypeJson(array $attributes): array
{
return array_map(function ($attribute) {
return $attribute->getLabel();
}, array_filter($attributes, function ($attribute) {
return $attribute->getType() === 'json';
}));
}
private function transformArrayToCsv(Statement $stmt, array $attributes, string $delimiter): string
{
$attributesLabel = array_map(function ($attribute) {
return $attribute->getLabel();
}, $attributes);
$csv = implode($delimiter, $attributesLabel) . PHP_EOL;
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$csv .= implode($delimiter, $row) . PHP_EOL;
}
return $csv;
}
}
......@@ -78,7 +78,7 @@ class Dataset implements \JsonSerializable
protected $dataPath;
/**
* @var string
* @var array
*
* @Column(type="json", nullable=true)
*/
......@@ -108,7 +108,7 @@ class Dataset implements \JsonSerializable
protected $datasetFamily;
/**
* @var Anis\Entity\Attribute[]
* @var Attribute[]
*
* @OneToMany(targetEntity="Attribute", mappedBy="dataset")
*/
......
......@@ -15,7 +15,6 @@ namespace App\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Exception\HttpUnauthorizedException;
use Nyholm\Psr7\Response as NyholmResponse;
use Psr\Http\Server\MiddlewareInterface;
use Firebase\JWT\JWT;
......
<?php
/*
* This file is part of Anis Server.
*
* (c) Laboratoire d'Astrophysique de Marseille / CNRS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace App\Search\Query;
use App\Entity\Dataset;
use App\Entity\Attribute;
abstract class AbstractQueryPart implements IQueryPart
{
/**
* Returns the Attribute object of a dataset based on its ID (primary key)
*
* @param Dataset $dataset Represents the requested dataset
* @param int $id Unique identifier for the requested attribute
* @return Attribute Returns the attribute found
* @throws SearchException Attribute with ID not found into the selected dataset
*/
protected function getAttribute(Dataset $dataset, int $id): Attribute
{
$attributes = $dataset->getAttributes();
foreach ($attributes as $attribute) {
if ($attribute->getId() === $id) {
return $attribute;
}
}
throw SearchQueryException::attributeNotFound($id, $dataset->getLabel());
}
}
<?php
/*
* This file is part of Anis Server.
*
* (c) Laboratoire d'Astrophysique de Marseille / CNRS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace App\Search\Query;
use Doctrine\DBAL\Query\QueryBuilder as DoctrineQueryBuilder;
use Doctrine\DBAL\Driver\ResultStatement;
use App\Search\Query\IQueryPart;
use App\Entity\Dataset;
use App\Entity\Attribute;
/**
* Class used to wrap the Doctrine DBAL Query Builder
*
* @author François Agneray <francois.agneray@lam.fr>
* @package App\Search
*/
class AnisQueryBuilder
{
/**
* @var DoctrineQueryBuilder
*/
private $doctrineQueryBuilder;
/**
* @var IQueryPart[]
*/
private $queryParts;
/**
* @var Attribute[]
*/
private $attributesSelected;
/**
* Returns Doctrine DBAL query builder
*
* @return DoctrineQueryBuilder
*/
public function getDoctrineQueryBuilder(): DoctrineQueryBuilder
{
return $this->doctrineQueryBuilder;
}
/**
* Set Doctrine DBAL Query Builder
*
* @param DoctrineQueryBuilder $doctrineQueryBuilder Represents the doctrine DBAL query being built
*/
public function setDoctrineQueryBuilder(DoctrineQueryBuilder $doctrineQueryBuilder)
{
$this->doctrineQueryBuilder = $doctrineQueryBuilder;
}
/**
* Returns the query attributes selected
*
* @return Attribute[]
*/
public function getAttributesSelected(): array
{
return $this->attributesSelected;
}
/**
* Keep the query attributes selected
*
* @param Attribute[] $attributesSelected Attributes selected
*/
public function setAttributesSelected(array $attributesSelected): void
{
$this->attributesSelected = $attributesSelected;
}
/**
* Adding a query part function will be executed to build the final query
*
* @param IQueryPart $queryPart The query part object like, for example, Where
* @return AnisQueryBuilder
*/
public function addQueryPart(IQueryPart $queryPart): AnisQueryBuilder
{
$this->queryParts[] = $queryPart;
return $this;
}
/**
* Executes all query parts added to build the final query
*
* @param string[] $queryParams The query params of the url (after ?)
*/
public function build(Dataset $dataset, array $queryParams): void
{
foreach ($this->queryParts as $queryPart) {
$queryPart($this, $dataset, $queryParams);
}
}
/**
* Executes this query using the bound parameters and their types.
*
* @return ResultStatement|int
*
* @throws Exception
*/
public function execute(): ResultStatement | int
{
return $this->doctrineQueryBuilder->execute();
}
}
<?php
/*
* This file is part of Anis Server.
*
* (c) Laboratoire d'Astrophysique de Marseille / CNRS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace App\Search\Query;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use App\Entity\Dataset;
/**
* Represents the Anis Cone-Search Query Part
*
* @author François Agneray <francois.agneray@lam.fr>
* @package App\Search\Query