You can use this symfony template to query elasticsearch index that depends on mongodb. This example depends on FOSElasticaBundle.


Setup


You need to install friendsofsymfony/elastica-bundle, doctrine/mongodb-odm and doctrine/mongodb-odm-bundle. At the time of writing this post, friendsofsymfony/elastica-bundle package was supporting only elasticsearch 1.7.4 and below. Add new FOS\ElasticaBundle\FOSElasticaBundle() and new Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle() to AppKernel.php file.


Config.yml


MongoDB


doctrine_mongodb:
default_database: mongodb_database
default_connection: mongodb
default_document_manager: mongodb
connections:
mongodb:
server: mongodb://localhost:27017
options:
connect: true
db: mongodb_database
document_managers:
mongodb:
connection: mongodb
database: mongodb_database
retry_connect: 3
retry_query: 3
mappings:
ApplicationMongoBundle: ~

Elasticsearch


This is just an example index. Based on the config, only the "title" and "description" fields can be full-text searched and also they can be used in sorting. Read String Sorting and Multifields for more information. The "id" field should be defined as a "string" in the case of MongoDB because @MongoDB\Id represents a string ID, not an integer like in the case of MySQL @ORM\Id.


fos_elastica:
clients:
default: { host: 127.0.0.1, port: 9203 }
indexes:
post_index:
client: default
index_name: post_%kernel.environment%
types:
post:
mappings:
id:
type: string
index: not_analyzed
title:
type: string
analyzer: english
fields:
raw:
type: string
index: not_analyzed
description:
type: string
analyzer: english
fields:
raw:
type: string
index: not_analyzed
year:
type: integer
index: not_analyzed
price:
type: double
index: not_analyzed
is_published:
type: boolean
index: not_analyzed
created_at:
type: date
index: not_analyzed
persistence:
driver: mongodb
model: Application\MongoBundle\Document\Post
finder: ~
provider: ~
listener: ~

Post document


As you can see above, we didn't map "author" field in elasticsearch config so it is not useful for us in ES.


namespace Application\MongoBundle\Document;

use DateTime;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
use Doctrine\ODM\MongoDB\Mapping\Annotations\Date;
use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;

/**
* @MongoDB\Document(collection="post")
* @MongoDB\HasLifecycleCallbacks
*/
class Post
{
/**
* @MongoDB\Id
*/
private $id;

/**
* @MongoDB\String
*/
private $title;

/**
* @MongoDB\String
*/
private $description;

/**
* @MongoDB\String
*/
private $author;

/**
* @MongoDB\Int
*/
private $year;

/**
* @MongoDB\String
*/
private $price;

/**
* @MongoDB\Boolean
*/
private $isPublished;

/**
* @MongoDB\Date
*/
private $createdAt;
}

SearchController


As you can see below, our search URL can be something like GET /search/mongo/elasticsearch?page=1&limit=10&sort=title,-description&fields=title,description&keywords=hello%20world.


namespace Application\MongoBundle\Controller;

use Application\MongoBundle\Service\PostElasticsearchServiceInterface;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\HttpFoundation\Request;

/**
* @Route("/search/mongo", service="application_mongo.controller.search")
*/
class SearchController extends Controller
{
private $postElasticsearchService;

public function __construct(PostElasticsearchServiceInterface $postElasticsearchService)
{
$this->postElasticsearchService = $postElasticsearchService;
}

/**
* @param Request $request
*
* @Method({"GET"})
* @Route("/elasticsearch")
* @Template()
*
* @return array
*/
public function searchElasticsearchAction(Request $request)
{
$page = $request->query->get('page', 1);
$limit = $request->query->get('limit', 10);
$sort = $request->query->get('sort');
$fields = $request->query->get('fields');
$keywords = $request->query->get('keywords');

$posts = $this->postElasticsearchService->getPosts($page, $limit, $sort, $fields, $keywords);

return [
'page' => $page,
'limit' => $limit,
'sort' => $sort,
'fields' => $fields,
'keywords' => $keywords,
'posts' => $posts
];
}
}

Controllers.yml


services:
application_mongo.controller.search:
class: Application\MongoBundle\Controller\SearchController
arguments:
- @application_mongo.service.post_elasticsearch

PostElasticsearchServiceInterface


namespace Application\MongoBundle\Service;

interface PostElasticsearchServiceInterface
{
public function getPosts($page, $limit, $sort = null, $fields = null, $keywords = null);
}

PostElasticsearchService


namespace Application\MongoBundle\Service;

use Application\MongoBundle\Factory\PostFactoryInterface;
use Application\MongoBundle\Util\Elasticsearch;

class PostElasticsearchService implements PostElasticsearchServiceInterface
{
private $elasticsearch;
private $postFactory;

public function __construct(
Elasticsearch $elasticsearch,
PostFactoryInterface $postFactory
) {
$this->elasticsearch = $elasticsearch;
$this->postFactory = $postFactory;
}

public function getPosts($page, $limit, $sort = null, $fields = null, $keywords = null)
{
return $this->postFactory->createPostResult(
$this->elasticsearch->find($page, $limit, $sort, $fields, $keywords)
);
}
}

Services.yml


services:
application_mongo.service.post_elasticsearch:
class: Application\MongoBundle\Service\PostElasticsearchService
arguments:
- @application_mongo.util.elasticsearch
- @application_mongo.factory.post

PostFactoryInterface


namespace Application\MongoBundle\Factory;

interface PostFactoryInterface
{
public function createPostResult(array $result = []);
}

PostFactory


namespace Application\MongoBundle\Factory;

use Application\MongoBundle\Model\Post;
use Elastica\Result as ElasticaResult;
use Application\MongoBundle\Model\Result;

class PostFactory implements PostFactoryInterface
{
public function createPostResult(array $result = [])
{
$postResult = new Result();
$postResult->page = $result['page'];
$postResult->limit = $result['limit'];
$postResult->total = $result['total'];
$postResult->posts = $this->getPosts($result['data']);

return $postResult;
}

private function getPosts(array $posts = [])
{
$data = [];

/** @var ElasticaResult $result */
foreach ($posts as $result) {
$post = new Post();
$post->id = $result->getSource()['id'];
$post->title = $result->getSource()['title'];
$post->description = $result->getSource()['description'];
$post->year = $result->getSource()['year'];
$post->price = $result->getSource()['price'];
$post->isPublished = $result->getSource()['is_published'];
$post->createdAt = $result->getSource()['created_at'];

$data[] = $post;
}

return $data;
}
}

Factories.yml


services:
application_mongo.factory.post:
class: Application\MongoBundle\Factory\PostFactory

Elasticsearch utility class


If you have more specific queries then you should update getSearchByQuery() method. It currently handles basic full-text searches.


namespace Application\MongoBundle\Util;

use Elastica\Query;
use Elastica\Type;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

class Elasticsearch
{
const ANALYSED_SUFFIX = '.raw';

// These fields must be set as "multivalue" fields in config.yml
private $stringSortingMultivalueFields = ['title', 'description'];
private $postType;

public function __construct(Type $postType)
{
$this->postType = $postType;
}

public function find($page, $limit, $sort = null, $fields = null, $keywords = null)
{
$page = $page > 0 ? $page : 1;
$query = null;

if (!$fields && !$keywords) {
$query = $this->getListByQuery($sort);
} elseif ($fields && $keywords) {
$query = $this->getSearchByQuery($sort, $fields, $keywords);
}

if (!$query) {
throw new BadRequestHttpException('Invalid request.');
}

$queryObject = (new Query($query))
->setSize($limit)
->setFrom($page-1);

$result = $this->postType->search($queryObject);

return [
'page' => $page,
'limit' => $limit,
'total' => $result->getTotalHits(),
'data' => $result->getResults()
];
}

private function getListByQuery($sort = null)
{
$query = [];

$query['query']['bool']['must'][]['match_all'] = [];
$query['sort'] = $this->getSort($sort);

return $query;
}

private function getSearchByQuery($sort, $fields, $keywords)
{
$query = [];

$searchFields = explode(',', $fields);
if (count($searchFields) == 1) {
$query['query']['match'][$searchFields[0]]['query'] = $keywords;
} else {
$query['query']['multi_match']['type'] = 'most_fields';
$query['query']['multi_match']['query'] = $keywords;
$query['query']['multi_match']['fields'] = $searchFields;
}
$query['sort'] = $this->getSort($sort, true);

return $query;
}

private function getSort($sort = null, $search = false)
{
$id = true;
$query = [];

// Applies to only "search" requests
if ($search) {
$query[]['_score']['order'] = 'desc';
}

if (!is_null($sort)) {
$sortFields = explode(',', $sort);
foreach ($sortFields as $sortField) {
// If the field is one of string sorting ones then use not_analyzed "*.raw" version
if (in_array($sortField, $this->stringSortingMultivalueFields)) {
$sortField = $sortField.self::ANALYSED_SUFFIX;
}

// Get correct direction
if (substr($sortField, 0, 1) === '-') {
$sortField = substr($sortField, 1);
$query[][$sortField]['order'] = 'desc';
} else {
$query[][$sortField]['order'] = 'asc';
}

if ($sortField == 'id') {
$id = false;
}
}

// If "id" field is not specified by user in request then include it by default
if ($id) {
$query[]['id']['order'] = 'asc';
}
} else {
$query[]['id']['order'] = 'asc';
}

return $query;
}
}

Utils.yml


Service name for elasticsearch is auto generated so based on the config.yml file @fos_elastica.index.indexes_name.type_name becomes @fos_elastica.index.post_index.post.


services:
application_mongo.util.elasticsearch:
class: Application\MongoBundle\Util\Elasticsearch
arguments:
- @fos_elastica.index.post_index.post

Result model class


namespace Application\MongoBundle\Model;

class Result
{
/**
* @var int
*/
public $page;

/**
* @var int
*/
public $limit;

/**
* @var int
*/
public $total;

/**
* @var Post[]
*/
public $posts = [];
}

Post model class


namespace Application\ModelBundle\Model;

class Post
{
/**
* @var string
*/
public $id;

/**
* @var string
*/
public $title;

/**
* @var string
*/
public $description;

/**
* @var string
*/
public $author;

/**
* @var int
*/
public $year;

/**
* @var double
*/
public $price;

/**
* @var boolean
*/
public $isPublished;

/**
* @var string
*/
public $createdAt;
}

PopulatePostCommand


Use this command $ app/console populate:post --total=5000 to populate your database first.


namespace Application\MongoBundle\Command;

use Application\MongoBundle\Document\Post;
use Doctrine\ODM\MongoDB\DocumentManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class PopulatePostCommand extends Command
{
const TOTAL = 'total';

private $documentManager;
private $authors = ['Robert DeNiro', 'Robert', 'DeNiro', 'Al Pacino', 'Al', 'Pacino', 'Andy Garcia', 'Andy'];
private $titles = ['Title', 'Title 1', 'Title 2', 'Title 3', 'Eltit', 'Eltit A', 'Eltit B', 'Desc', 'Desc 3'];
private $descriptions = ['Desc', 'Desc 1', 'Desc 2', 'Cript', 'Cript A', 'Cript B', 'Title', 'Title 3'];
private $years = [2000, 2005, 2010, 2015];
private $prices = [0.5, 1, 2.5, 3.99, 4.00, 5.55];

public function __construct(DocumentManager $documentManager)
{
parent::__construct();

$this->documentManager = $documentManager;
}

protected function configure()
{
$this
->setName('populate:post')
->setDescription('Populates post entity.')
->addOption(
self::TOTAL,
null,
InputOption::VALUE_REQUIRED,
'Defines total amount of records to be created.'
);
}

protected function execute(InputInterface $input, OutputInterface $output)
{
for ($i = 0; $i < $input->getOption(self::TOTAL); $i++) {
$post = new Post();
$post->setAuthor($this->authors[array_rand($this->authors)]);
$post->setTitle($this->titles[array_rand($this->titles)]);
$post->setDescription($this->descriptions[array_rand($this->descriptions)]);
$post->setYear($this->years[array_rand($this->years)]);
$post->setPrice($this->prices[array_rand($this->prices)]);
$post->setIsPublished(array_rand([true, false]));

$this->documentManager->persist($post);
if ($i%10 == 0) {
$this->documentManager->flush();
}
}
$this->documentManager->flush();
}
}

Populate elasticsearch


After running this command, mongodb storage footprint of your database on disk (fileSize) will be about 67108864.000000 byte (64MB). You can verify it by running db.stats() command.


$ app/console fos:elastica:populate
5000/5000 [============================] 100%
Populating post_index/post
Refreshing post_index
Refreshing post_index

Tests


Just listing without sort property


# Request
GET /search/mongo/elasticsearch?page=1&limit=10

# Response array
Array
(
[query] => Array
(
[bool] => Array
(
[must] => Array
(
[0] => Array
(
[match_all] => Array
(
)
)
)
)
)
[sort] => Array
(
[0] => Array
(
[id] => Array
(
[order] => asc
)
)
)
[from] => 0
[size] => 10
)

# Response json
{
"query": {
"bool": {
"must": [
{
"match_all": [

]
}
]
}
},
"sort": [
{
"id": {
"order": "asc"
}
}
],
"from": 0,
"size": "10"
}

Just listing with sort property


# Request
GET /search/mongo/elasticsearch?page=1&limit=10&sort=title,-year

# Response array
Array
(
[query] => Array
(
[bool] => Array
(
[must] => Array
(
[0] => Array
(
[match_all] => Array
(
)
)
)
)
)
[sort] => Array
(
[0] => Array
(
[title.raw] => Array
(
[order] => asc
)
)
[1] => Array
(
[year] => Array
(
[order] => desc
)
)
[2] => Array
(
[id] => Array
(
[order] => asc
)
)
)
[from] => 0
[size] => 10
)

# Response json
{
"query": {
"bool": {
"must": [
{
"match_all": [

]
}
]
}
},
"sort": [
{
"title.raw": {
"order": "asc"
}
},
{
"year": {
"order": "desc"
}
},
{
"id": {
"order": "asc"
}
}
],
"from": 0,
"size": "10"
}

Single field search


# Request
GET /search/mongo/elasticsearch?page=1&limit=10&sort=description&fields=title&keywords=inanzzz

# Response array
Array
(
[query] => Array
(
[match] => Array
(
[title] => Array
(
[query] => inanzzz
)
)
)
[sort] => Array
(
[0] => Array
(
[_score] => Array
(
[order] => desc
)
)
[1] => Array
(
[description.raw] => Array
(
[order] => asc
)
)
[2] => Array
(
[id] => Array
(
[order] => asc
)
)
)
[from] => 0
[size] => 10
)

# Response json
{
"query": {
"match": {
"title": {
"query": "inanzzz"
}
}
},
"sort": [
{
"_score": {
"order": "desc"
}
},
{
"description.raw": {
"order": "asc"
}
},
{
"id": {
"order": "asc"
}
}
],
"from": 0,
"size": "10"
}

Multi fields search


# Request
GET /search/mongo/elasticsearch?page=1&limit=10&sort=description&fields=title,description&keywords=inanzzz

# Response array
Array
(
[query] => Array
(
[multi_match] => Array
(
[type] => most_fields
[query] => inanzzz
[fields] => Array
(
[0] => title
[1] => description
)
)
)
[sort] => Array
(
[0] => Array
(
[_score] => Array
(
[order] => desc
)
)
[1] => Array
(
[description.raw] => Array
(
[order] => asc
)
)
[2] => Array
(
[id] => Array
(
[order] => asc
)
)
)
[from] => 0
[size] => 10
)

# Response json
{
"query": {
"multi_match": {
"type": "most_fields",
"query": "inanzzz",
"fields": [
"title",
"description"
]
}
},
"sort": [
{
"_score": {
"order": "desc"
}
},
{
"description.raw": {
"order": "asc"
}
},
{
"id": {
"order": "asc"
}
}
],
"from": 0,
"size": "10"
}