From e7f6b855e648ee7473d424c349d3f40fbdd3cb06 Mon Sep 17 00:00:00 2001 From: Gustavo Piltcher Date: Sun, 10 Mar 2013 23:14:15 -0300 Subject: [PATCH 1/3] Added support to service side column control; - Added support to service side column control; - Added support to where collections functions callback; - Added support to customized variables using (aoCustomVars) - Added support to row id prefixes; --- Datatables/Datatable.php | 243 ++++++++++++++++++++++++++++++-- Datatables/DatatableManager.php | 138 +++++++++--------- Resources/doc/index.md | 144 ++++++++++++++++++- 3 files changed, 442 insertions(+), 83 deletions(-) diff --git a/Datatables/Datatable.php b/Datatables/Datatable.php index 8b746f0..1fd00e4 100755 --- a/Datatables/Datatable.php +++ b/Datatables/Datatable.php @@ -62,11 +62,32 @@ class Datatable */ const RESULT_RESPONSE = 'Response'; + /** + * Column property: sTitle + */ + const COLUMN_TITLE = 'sTitle'; + + /** + * Column property: searchable + */ + const COLUMN_SEARCHABLE = 'bSearchable'; + + /** + * Column property: sortable + */ + const COLUMN_SORTABLE = 'bSortable'; + + /** + * Custom variable: sDtRowIdPrefix + */ + const CUSTOM_VAR_ROW_ID_PREFIX = 'sDtRowIdPrefix'; + /** * @var array Holds callbacks to be used */ protected $callbacks = array( 'WhereBuilder' => array(), + 'WhereCollection' => array() ); /** @@ -84,6 +105,11 @@ class Datatable */ protected $useDtRowId = false; + /** + * @var string If $useDtRowId is set to true then an id will be appended to each row, you can also specify a string to be concatenated in the beginning of each row id + */ + protected $dtRowIdPrefix; + /** * @var string Whether or not to add DT_RowClass to each record if it is set */ @@ -134,6 +160,16 @@ class Datatable */ protected $parameters; + /** + * @var array A map with columns server-side defined + */ + protected $aoColumns; + + /** + * @var array A map with custom vars server-side defined + */ + protected $aoCustomVars; + /** * @var array Information relating to the specific columns requested */ @@ -194,25 +230,50 @@ class Datatable */ protected $datatable; - public function __construct(array $request, EntityRepository $repository, ClassMetadata $metadata, EntityManager $em, $serializer) + /** + * @var boolean A flag to control where aoColumns.mDataProp is defined, if used on server side then you need to use addColumn method + */ + protected $serverSideControl; + + /** + * @var array A map between column key name and the association map dql fullName + */ + protected $columnsDqlPartName; + + public function __construct(array $request, EntityRepository $repository, ClassMetadata $metadata, EntityManager $em, $serializer, $serverSideControl) { $this->em = $em; $this->request = $request; $this->repository = $repository; $this->metadata = $metadata; $this->serializer = $serializer; + $this->serverSideControl = $serverSideControl; $this->tableName = Container::camelize($metadata->getTableName()); $this->defaultJoinType = self::JOIN_INNER; $this->defaultResultType = self::RESULT_RESPONSE; - $this->setParameters(); + if ($this->serverSideControl === false) { + if (sizeof($this->request) == 0 || count(array_diff(array('iColumns', 'sEcho', 'sSearch', 'iDisplayStart', 'iDisplayLength'), array_keys($this->request)))) { + throw new \Exception('Unable to recognize a datatables.js valid request.'); + } + $this->setParameters(); + } $this->qb = $em->createQueryBuilder(); - $this->echo = $this->request['sEcho']; - $this->search = $this->request['sSearch']; - $this->offset = $this->request['iDisplayStart']; - $this->amount = $this->request['iDisplayLength']; $identifiers = $this->metadata->getIdentifierFieldNames(); $this->rootEntityIdentifier = array_shift($identifiers); + + // Default vars to inject into 'aoCustomVars' when using server side control + $this->aoCustomVars = array(); + + if (sizeof($this->request) > 0) { + $this->echo = $this->request['sEcho']; + $this->search = $this->request['sSearch']; + $this->offset = $this->request['iDisplayStart']; + $this->amount = $this->request['iDisplayLength']; + $this->dtRowIdPrefix = isset($this->request[self::CUSTOM_VAR_ROW_ID_PREFIX]) + ? $this->request[self::CUSTOM_VAR_ROW_ID_PREFIX] + : ''; + } } /** @@ -272,7 +333,8 @@ public function setParameters() $params = array(); $associations = array(); for ($i=0; $i < intval($this->request['iColumns']); $i++) { - $fields = explode('.', $this->request['mDataProp_' . $i]); + $key = $this->request['mDataProp_' . $i]; + $fields = explode('.', $key); $params[] = $this->request['mDataProp_' . $i]; $associations[] = array('containsCollections' => false); @@ -286,6 +348,83 @@ public function setParameters() } } + /** + * Add a new column to the Datatable object + * + * Parse and configure parameter/association using a per addColumn basis and also configures aoColumns to be used + * as a retrieved object by DataTables.js (it automatically includes mDataProp using the provided $key value) + * + * @param $key A dotted-notation property format key used by DQL to fetch your object. Use the property from the object that you provided to getDatatable() method + * @param array $rawOptions (optional) A map with raw keys used by datatables.js aoColumns property + */ + public function addColumn($key, $rawOptions = null) + { + if (!$this->serverSideControl) { + throw new \Exception('This method is not allowed to use if you are not using server-side control datatables.'); + } + + if (is_null($rawOptions)) { + $rawOptions = array(); + } + + $rawOptions['mDataProp'] = $key; + $this->aoColumns[] = $rawOptions; + $this->parameters[] = $key; + + $fields = explode('.', $key); + $association = array('containsCollections' => false); + + if (count($fields) > 1) { + $this->setRelatedEntityColumnInfo($association, $fields); + } else { + $this->setSingleFieldColumnInfo($association, $fields[0]); + } + + $this->associations[] = $association; + + return $this; + } + + /** + * Gets the DQL field name of a DataTables column + * + * @param string $key A key used as reference to a DataTables column + * @return string|null The DQL field name extracted from DataTables column key + */ + public function getColumnDQLPartName($key) + { + if (!isset($this->columnsDqlPartName[$key])) { + throw new \Exception(sprintf( + "A missing key ['%s'] was detected in your datatable object when \"%s()\" method was called.", + $key, + __FUNCTION__ + )); + } + + return $this->columnsDqlPartName[$key]; + } + + /** + * Add column dql part name + * + * @param string $key A key used as reference to a DataTables column + */ + public function addColumnDQLPartName($key) + { + $fields = explode('.', $key); + $association = array('containsCollections' => false); + + if (count($fields) > 1) { + $this->setRelatedEntityColumnInfo($association, $fields); + } else { + $this->setSingleFieldColumnInfo($association, $fields[0]); + } + + $this->columnsDqlPartName[$key] = $association['fullName']; + + return $this; + } + /** * Parse a dotted-notation column format from the mData, and sets association * information @@ -344,6 +483,7 @@ protected function setRelatedEntityColumnInfo(array &$association, array $fields $association['fieldName'] = $lastField; $association['joinName'] = $joinName; $association['fullName'] = $this->getFullName($association); + $this->columnsDqlPartName[$mdataName] = $association['fullName']; } /** @@ -353,6 +493,7 @@ protected function setRelatedEntityColumnInfo(array &$association, array $fields * @param string The field name on the main entity */ protected function setSingleFieldColumnInfo(array &$association, $fieldName) { + $key = $fieldName; $fieldName = Container::camelize($fieldName); if (!$this->metadata->hasField(lcfirst($fieldName))) { @@ -365,6 +506,7 @@ protected function setSingleFieldColumnInfo(array &$association, $fieldName) { $association['fieldName'] = $fieldName; $association['entityName'] = $this->tableName; $association['fullName'] = $this->tableName . '.' . lcfirst($fieldName); + $this->columnsDqlPartName[$key] = $association['fullName']; } /** @@ -511,6 +653,16 @@ public function setWhere(QueryBuilder $qb) $callback($qb); } } + + if (!empty($this->callbacks['WhereCollection'])) { + foreach ($this->callbacks['WhereCollection'] as $callback) { + $whereCollection = $callback($qb->expr()); + if (!is_array($whereCollection)) + throw new \Exception(sprintf("The function %s must return an array", $callback)); + + $qb->andWhere($qb->expr()->andX()->addMultiple($whereCollection)); + } + } } /** @@ -606,6 +758,7 @@ public function executeSearch() $output = array("aaData" => array()); $query = $this->qb->getQuery()->setHydrationMode(Query::HYDRATE_ARRAY); + $items = $this->useDoctrinePaginator ? new Paginator($query, $this->doesQueryContainCollections()) : $query->execute(); @@ -616,7 +769,7 @@ public function executeSearch() $item['DT_RowClass'] = $this->dtRowClass; } if ($this->useDtRowId) { - $item['DT_RowId'] = $item[$this->rootEntityIdentifier]; + $item['DT_RowId'] = $this->dtRowIdPrefix . $item[$this->rootEntityIdentifier]; } // Results are already correctly formatted if this is the case... if (!$this->associations[$i]['containsCollections']) { @@ -648,6 +801,11 @@ public function executeSearch() "iTotalDisplayRecords" => $this->getCountFilteredResults() ); + if ($this->serverSideControl) { + $outputHeader['aoColumns'] = $this->aoColumns; + $outputHeader['aoCustomVars'] = $this->getAoCustomVars(); + } + $this->datatable = array_merge($outputHeader, $output); return $this; @@ -738,12 +896,24 @@ public function getCountAllResults() $qb = $this->repository->createQueryBuilder($this->tableName) ->select('count(' . $this->tableName . '.' . $this->rootEntityIdentifier . ')'); + $this->setAssociations($qb); + if (!empty($this->callbacks['WhereBuilder']) && $this->hideFilteredCount) { foreach ($this->callbacks['WhereBuilder'] as $callback) { $callback($qb); } } + if (!empty($this->callbacks['WhereCollection']) && $this->hideFilteredCount) { + foreach ($this->callbacks['WhereCollection'] as $callback) { + $whereCollection = $callback($qb->expr()); + if (!is_array($whereCollection)) + throw new \Exception(sprintf("The function %s must return an array", $callback)); + + $qb->andWhere($qb->expr()->andX()->addMultiple($whereCollection)); + } + } + return (int) $qb->getQuery()->getSingleScalarResult(); } @@ -771,6 +941,18 @@ public function addWhereBuilderCallback($callback) { return $this; } + /** + * @param object A callback function to be used at the end of 'setWhere' + */ + public function addWhereCollectionCallback($callback) { + if (!is_callable($callback)) { + throw new \Exception("The callback argument must be callable."); + } + $this->callbacks['WhereCollection'][] = $callback; + + return $this; + } + public function getOffset() { return $this->offset; @@ -795,4 +977,49 @@ public function getQueryBuilder() { return $this->qb; } + + /** + * Add a custom variable key value pair to aoCustomVars custom object + * @param $key The key name + * @param $value The value for key + */ + public function addCustomVar($key, $value) + { + $this->aoCustomVars[$key] = $value; + } + + /** + * Gets custom variables + * @param bool $formatted If true it will convert the key value map into a 'name' => $key, 'value' => $value object array as the standard pattern of datatables.js (optional. Default: false) + * @return array A key value map of custom vars + */ + public function getAoCustomVars($formatted = false) + { + if (!$formatted) { + return $this->aoCustomVars; + } + + $oArr = array(); + foreach ($this->getAoCustomVars() as $key => $value) { + $oArr[] = array('name' => $key, 'value' => $value); + } + + return $oArr; + } + + /** + * Sets a prefix for the DT_RowId + * (NOTE: this will be returned as a custom variable, you need to treat in your front-end app since datatables.js + * doesn't support it by default, you can use a callback to check server params, if 'aoCustomVars' exists and + * a 'sDtRowIdPrefix' is defined then just add the key/value pair to the 'fnServerParams' callback, this will force + * datatables to send the same variable back to the server again, then the server will process and parse that + * message to append properly the prefix to each row id) + * + * @param string $dtRowIdPrefix + */ + public function setDtRowIdPrefix($dtRowIdPrefix) + { + $this->dtRowIdPrefix = $dtRowIdPrefix; // This doesn't make difference since its the client side who really decides about the prefix + $this->addCustomVar(self::CUSTOM_VAR_ROW_ID_PREFIX, $dtRowIdPrefix); // This will make the things happen, it will add a custom var to be treated in the front-end side + } } diff --git a/Datatables/DatatableManager.php b/Datatables/DatatableManager.php index b7edf4f..128287f 100755 --- a/Datatables/DatatableManager.php +++ b/Datatables/DatatableManager.php @@ -1,68 +1,70 @@ -doctrine = $doctrine; - $this->container = $container; - $this->useDoctrinePaginator = $useDoctrinePaginator; - } - - /** - * Given an entity class name or possible alias, convert it to the full class name - * - * @param string The entity class name or alias - * @return string The entity class name - */ - protected function getClassName($className) { - if (strpos($className, ':') !== false) { - list($namespaceAlias, $simpleClassName) = explode(':', $className); - $className = $this->doctrine->getManager()->getConfiguration() - ->getEntityNamespace($namespaceAlias) . '\\' . $simpleClassName; - } - return $className; - } - - /** - * @param string An entity class name or alias - * @return object Get a DataTable instance for the given entity - */ - public function getDatatable($class) - { - $class = $this->getClassName($class); - - $metadata = $this->doctrine->getManager()->getClassMetadata($class); - $repository = $this->doctrine->getRepository($class); - - $datatable = new Datatable( - $this->container->get('request')->query->all(), - $this->doctrine->getRepository($class), - $this->doctrine->getManager()->getClassMetadata($class), - $this->doctrine->getManager(), - $this->container->get('lankit_datatables.serializer') - ); - return $datatable->useDoctrinePaginator($this->useDoctrinePaginator); - } -} - +doctrine = $doctrine; + $this->container = $container; + $this->useDoctrinePaginator = $useDoctrinePaginator; + } + + /** + * Given an entity class name or possible alias, convert it to the full class name + * + * @param string The entity class name or alias + * @return string The entity class name + */ + protected function getClassName($className) { + if (strpos($className, ':') !== false) { + list($namespaceAlias, $simpleClassName) = explode(':', $className); + $className = $this->doctrine->getManager()->getConfiguration() + ->getEntityNamespace($namespaceAlias) . '\\' . $simpleClassName; + } + return $className; + } + + /** + * @param string $class An entity class name or alias + * @param boolean $serverSideControl This is a control flag to choose if the access to DataTables information will be controlled on server side or not (default: false) + * @return object Get a DataTable instance for the given entity + */ + public function getDatatable($class, $serverSideControl = false) + { + $class = $this->getClassName($class); + + $metadata = $this->doctrine->getManager()->getClassMetadata($class); + $repository = $this->doctrine->getRepository($class); + + $datatable = new Datatable( + $this->container->get('request')->query->all(), + $this->doctrine->getRepository($class), + $this->doctrine->getManager()->getClassMetadata($class), + $this->doctrine->getManager(), + $this->container->get('lankit_datatables.serializer'), + $serverSideControl + ); + return $datatable->useDoctrinePaginator($this->useDoctrinePaginator); + } +} + diff --git a/Resources/doc/index.md b/Resources/doc/index.md index 1077e2a..bb1c16a 100755 --- a/Resources/doc/index.md +++ b/Resources/doc/index.md @@ -5,6 +5,7 @@ Getting Started With LanKitDatatablesBundle * [Installation](#installation) * [Usage](#usage) * [Entity Associations and Join Types](#entity-associations-and-join-types) +* [Using server side control](#server-side-control) * [Search Result Response Types](#search-result-response-types) * [Pre-Filtering Search Results](#pre-filtering-search-results) * [DateTime Formatting](#datetime-formatting) @@ -14,7 +15,7 @@ Getting Started With LanKitDatatablesBundle This bundle provides an intuitive way to process DataTables.js requests by using mData. The mData from the DataTables request corresponds to fields and associations on a specific entity. You can access related entities off the -base entity by using dottted notation. +base entity by using dotted notation. For example, a mData structure to query an entity may look like the following: @@ -35,6 +36,24 @@ limitations with entity associations. If an association is a collection (ie. many associated records), then an array of values are returned for the final field in question. +This bundle supports two kind of approaches: the default one is totally controlled +by the front-end application, it means that it will parse any required information +that your datatables.js would require through the `aoColumns` array of `mData` +properties. The second one is called `serverSideControl`, using this approach you +can restrict the information available for a user. It will throw an error if the +user injects a malicious javascript code to require restricted information, like: + +``` js + "aoColumns": [ + { "mData": "email" }, + { "mData": "password" } + ] +``` + +Behind the scenes `aoColumns` still in control of the information exchange with +the bundle, but its the bundle who will provided datatable.js the information of +which columns are available. + ## Prerequisites This version of the bundle requires Symfony 2.1+. This bundle also needs the JMSSerializerBundle @@ -158,12 +177,19 @@ public function getDatatableAction() { $datatable = $this->get('lankit_datatables')->getDatatable('AcmeDemoBundle:Customer'); + /* + * This is automatically for any coloumn of your dataTable, but on this case customer.isActive is + * a filtering criteria to be used inside a callback, so you need to add it manually. If the + * filtered variable is a column then you don't need to do anything. + */ + $dataTable->addColumnDQLPartName('customer.isActive'); + // Add the $datatable variable, or other needed variables, to the callback scope $datatable->addWhereBuilderCallback(function($qb) use ($datatable) { $andExpr = $qb->expr()->andX(); - // The entity is always referred to using the CamelCase of its table name - $andExpr->add($qb->expr()->eq('Customer.isActive','1')); + // The entity is referred using a helper method of Datatable object + $andExpr->add($qb->expr()->eq($dataTable->getColumnDQLPartName('customer.isActive'), '1')); // Important to use 'andWhere' here... $qb->andWhere($andExpr); @@ -174,10 +200,12 @@ public function getDatatableAction() } ``` -As noted above, all join names are done by using CamelCase on the table name of the entity. Related -entities are separated out from the main entity with an underscore. So an entity relation on `Customer` -called `Location` with a field name called `city`, would be referenced in QueryBuilder as -`Customer_Location.city` +As noted above, you get join names using `getColumnDQLPartName` method of your $datable object. You just need +to pass as an argument the key used on your `mData` property or the key used by `addColumn` method (if you are +using `serverSideControl` to control columns). +So an entity relation on `Customer` called `Location` with a field name called `city`, would be retrieved in +QueryBuilder with `getColumnDQLPartName` method of `$datatable` object using `customer.location.city` as its +key. By default, pre-filtered results will return a total count back to DataTables.js with the filtering applied. If you would like the total count to reflect the total number of entities before the pre-filtering was applied @@ -198,6 +226,108 @@ public function getDatatableAction() } ``` +A shortcut method called `addWhereCollectionCallback` is also available to add a callback function with +`where` instructions collections in the end of `setWhere`, the main difference to the previous one +(`addWhereBuilderCallback`) is that the developer don't need to worry about the QueryBuilder and also reduces +the risk of disrupting something while its working with more developers. + +``` php + +public function getDatatableAction() +{ + $datatable = $this->get('lankit_datatables')->getDatatable('AcmeDemoBundle:Customer'); + + $dataTable->addColumnDQLPartName('customer.isActive'); + $dataTable->addColumnDQLPartName('customer.area'); + + $datatable->addWhereCollectionCallback(function($expr) use ($datatable) { + return array( // Important to return an array here... + $expr->eq( + $dataTable->getColumnDQLPartName('customer.isActive'), + '1' + ), + $expr->neq( + $dataTable->getColumnDQLPartName('customer.area'), + $expr->literal(Customer::AREA_AGRICULTURE) + ) + ); + }); + + return $datatable->getSearchResults(); +} +``` + +As noted above this simplifies the way of extending the query filtering. All you need to do is to return an +array of `Doctrine\ORM\Query\Expr` objects in your callback function. All this objects will be automatically +added to a `andX` collection and then inserted into the Datatable QueryBuilder using a `andWhere` clause. + +## Using server side control + +By default the server side control is off, it means that the bundle will answer to every field request +from datatables.js. To change this behavior you first need to activate the server side control by providing +a second argument to `getDatatable` service method. The second step is to call `addColumn` method for every +desired information to be retrieved by datatables.js, let's consider the following: `description`, +`customer.lastname` and `customer.location.address`; So, the first argument is the attribute name using the +same entity relationship and property dotted notation as before. The second argument is an array map with +key => value pairs that represent some options of your column. It supports the raw notation of datables.js +`aoColumns` variable, but its recommended the use of some constant values to avoid mistakes. + +``` php + +public function getDatatableAction() +{ + // Notice the second argument, this will create a Datatable object server-side controlled + $datatable = $this->get('lankit_datatables')->getDatatable('AcmeDemoBundle:Customer', true); + $dataTable + ->addColumn( + 'customer.lastname', + array( + Datatable::COLUMN_TITLE => 'Last name' + ) + ) + ->addColumn( + 'description', + array( + Datatable::COLUMN_TITLE => 'Full description', + Datatable::COLUMN_SORTABLE => false + ) + ) + ->addColumn( + 'customer.location.address', + array( + Datatable::COLUMN_TITLE => 'Address', + Datatable::COLUMN_SORTABLE => false, + Datatable::COLUMN_SEARCHABLE => false + ) + ) + ; + + return $datatable->getSearchResults(); +} +``` + +The above code will not work without a minor modification in your front-end application, as explained before +this bundle uses the `mData` values of `aoColumns` to process all reqired data. Now, instead of forcing the +front-end developer to provide an entity level information, this data will be automatically returned through +`aoColumns` json object if the `serverSideControl` is set to true and if your columns are properly defined +inside your controllers actions. To make all of this happen just add a javascript on your code to make a +request to `sAjaxSource` before creating datatables.js instance, if you are using jQuery it will be something +like this: + +``` js +$(document).ready(function(){ + var sAjaxSource = 'http://URL_TO_GET_DATATABLE_ACTION'; + $.getJSON(sAjaxSource, function(dataTable){ + $('#example').dataTable({ + 'bProcessing': true, + 'bServerSide': true, + 'sAjaxSource': sAjaxSource, + 'aoColumns': dataTable.aoColumns + }); + }); +}); +``` + ## DateTime Formatting All formatting is handled by the serializer service in use (likely JMSSerializer). To change the DateTime From ea593333d5b83f58c3cecff40d1a6f7a96c18b63 Mon Sep 17 00:00:00 2001 From: Gustavo Piltcher Date: Mon, 11 Mar 2013 17:49:25 -0300 Subject: [PATCH 2/3] Added support to column filtering callback functions and fixed an issue with missing fields - Support to add callback function to filter on a per column basis; - Fixed an issue of missing fields from missing relationships, now even fields from missing relationships will be injected into the aaData response; --- Datatables/Datatable.php | 67 +++++++++++++++++++++++++++++++++++++--- Resources/doc/index.md | 57 ++++++++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/Datatables/Datatable.php b/Datatables/Datatable.php index 1fd00e4..55d30ae 100755 --- a/Datatables/Datatable.php +++ b/Datatables/Datatable.php @@ -87,7 +87,8 @@ class Datatable */ protected $callbacks = array( 'WhereBuilder' => array(), - 'WhereCollection' => array() + 'WhereCollection' => array(), + 'columnFilter' => array() ); /** @@ -356,11 +357,12 @@ public function setParameters() * * @param $key A dotted-notation property format key used by DQL to fetch your object. Use the property from the object that you provided to getDatatable() method * @param array $rawOptions (optional) A map with raw keys used by datatables.js aoColumns property + * @param callback $filterCallback (optional) A filter callback to be applied to the current column after retrieved from QueryBuilder; */ - public function addColumn($key, $rawOptions = null) + public function addColumn($key, $rawOptions = null, $filterCallback = null) { if (!$this->serverSideControl) { - throw new \Exception('This method is not allowed to use if you are not using server-side control datatables.'); + throw new \Exception(sprintf("The \"%s\" method is not allowed to use if you are not using server-side control datatables.", __FUNCTION__)); } if (is_null($rawOptions)) { @@ -381,10 +383,31 @@ public function addColumn($key, $rawOptions = null) } $this->associations[] = $association; + if (!is_null($filterCallback)) { + $this->addColumnFilter($key, $filterCallback); + } return $this; } + /** + * Adds a function to filter the value returned by key + * + * @param string $key Your key name + * @param callback $filterCallback The function used to filter the result value of $key + * @throws \Exception If the filterCallback is not a callable function + */ + public function addColumnFilter($key, $filterCallback) + { + if (!is_callable($filterCallback)) { + throw new \Exception(sprintf("The second argument of \"%s\" method must be a callable function", __FUNCTION__)); + } + if (!isset($this->callbacks['columnFilter'][$key])) { + $this->callbacks['columnFilter'][$key] = array(); + } + $this->callbacks['columnFilter'][$key][] = $filterCallback; + } + /** * Gets the DQL field name of a DataTables column * @@ -405,9 +428,14 @@ public function getColumnDQLPartName($key) } /** - * Add column dql part name + * Automatically sets the DQL field name of a DataTables column based on its key * - * @param string $key A key used as reference to a DataTables column + * You should use this method when you need to call getColumnDQLPartName method inside a filter callback for a entity + * field that does not belongs to your datatable.js instance, but somehow you need to use it to do some filtering or + * whatever. + * + * @param string $key A dotted notation key value of your entity field + * @return Datatable */ public function addColumnDQLPartName($key) { @@ -765,6 +793,7 @@ public function executeSearch() foreach ($items as $item) { // Go through each requested column, transforming the array as needed for DataTables for ($i = 0 ; $i < count($this->parameters); $i++) { + $parameterKey = $this->parameters[$i]; if ($this->useDtRowClass && !is_null($this->dtRowClass)) { $item['DT_RowClass'] = $this->dtRowClass; } @@ -773,6 +802,9 @@ public function executeSearch() } // Results are already correctly formatted if this is the case... if (!$this->associations[$i]['containsCollections']) { + $item[$parameterKey] = isset($item[$parameterKey]) ? $item[$parameterKey] : null; // Inject missing parameters + $this->applyColumnFiltering($parameterKey, $item[$parameterKey]); // Apply column filtering if needed + continue; } @@ -789,9 +821,13 @@ public function executeSearch() $children = array_merge_recursive($children, $childItem); } $rowRef = $children; + } else { // Only leaf nodes... + $rowRef[$field] = isset($rowRef[$field]) ? $rowRef[$field] : null; // Inject missing parameters + $this->applyColumnFiltering($parameterKey, $rowRef); } } } + $output['aaData'][] = $item; } @@ -811,6 +847,27 @@ public function executeSearch() return $this; } + /** + * Apply a columnFilter to the column value $value identified by $key. + * + * @param string $key Column key + * @param mixed $value A value of any type passed by reference + * @return bool It return false if no filtering was applied to the column, or true if the filter was applied + */ + private function applyColumnFiltering($key, &$value) + { + if (!isset($this->callbacks['columnFilter'][$key])) { + return false; + } + + $columnFilterCallback = $this->callbacks['columnFilter'][$key]; + foreach ($columnFilterCallback as $callback) { + $value = $callback($value); + } + + return true; + } + /** * @return boolean Whether any mData contains an association that is a collection */ diff --git a/Resources/doc/index.md b/Resources/doc/index.md index bb1c16a..0dee1e8 100755 --- a/Resources/doc/index.md +++ b/Resources/doc/index.md @@ -5,10 +5,11 @@ Getting Started With LanKitDatatablesBundle * [Installation](#installation) * [Usage](#usage) * [Entity Associations and Join Types](#entity-associations-and-join-types) -* [Using server side control](#server-side-control) +* [Using Server Side Control](#server-side-control) * [Search Result Response Types](#search-result-response-types) * [Pre-Filtering Search Results](#pre-filtering-search-results) * [DateTime Formatting](#datetime-formatting) +* [Column Post-filtering](#column-post-filtering) * [DT_RowId and DT_RowClass](#dt_rowid-and-dt_rowclass) * [The Doctrine Paginator and MS SQL](#the-doctrine-paginator-and-ms-sql) @@ -261,7 +262,7 @@ As noted above this simplifies the way of extending the query filtering. All you array of `Doctrine\ORM\Query\Expr` objects in your callback function. All this objects will be automatically added to a `andX` collection and then inserted into the Datatable QueryBuilder using a `andWhere` clause. -## Using server side control +## Using Server Side Control By default the server side control is off, it means that the bundle will answer to every field request from datatables.js. To change this behavior you first need to activate the server side control by providing @@ -330,7 +331,7 @@ $(document).ready(function(){ ## DateTime Formatting -All formatting is handled by the serializer service in use (likely JMSSerializer). To change the DateTime +All formatting is generally handled by the serializer service in use (likely JMSSerializer). To change the DateTime formatting when using the JMSSerializer you can either use annotation or define a default format in your `app/config/config.yml` file. @@ -363,6 +364,56 @@ use Doctrine\ORM\Mapping as ORM; For more details on formatting output, please refer to [this document](http://jmsyst.com/libs/serializer/master/reference/annotations). +## Column Post-filtering + +You can also use a callback function to filter values from your columns, using this approach you can define your own logic to deal with +any kind of formatting, translation (if not DB based), transformation, etc. You can filter any value/object to whatever you want before +they get returned to datatables.js instance in your application front-end. + +To use this feature you have two alternatives, the first one by using a third argument on `addColumn` method: + +```php +public function getDatatableAction() +{ + $datatable = $this->get('lankit_datatables')->getDatatable('AcmeDemoBundle:Customer', true); + $dataTable + ->addColumn( + 'customer.created', + array( + Datatable::COLUMN_TITLE => 'Created at' + ), + function ($v) { + return $v->format('m/d/Y H:i:s'); + } + ) + ; + + return $datatable->getSearchResults(); +} +``` + +And the second one using `addColumnFilter` method: + +```php +public function getDatatableAction() +{ + $datatable = $this->get('lankit_datatables')->getDatatable('AcmeDemoBundle:Customer', true); + $dataTable + ->addColumn( + 'customer.created', + array( + Datatable::COLUMN_TITLE => 'Created at' + ) + ) + ; + + $datatable->addColumnFilter('customer.created', function ($v) { + return $v->format('m/d/Y H:i:s'); + }); + return $datatable->getSearchResults(); +} +``` + ## DT_RowId and DT_RowClass The properties DT_RowId and DT_RowClass are special DataTables.js properties. See the following article... From 00a2162f7ad5008ac972591e77b561e8969541f0 Mon Sep 17 00:00:00 2001 From: Gustavo Piltcher Date: Tue, 12 Mar 2013 11:58:24 -0300 Subject: [PATCH 3/3] Fixed an issue with empty arrays being retured in the where collection callback functions; --- Datatables/Datatable.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Datatables/Datatable.php b/Datatables/Datatable.php index 55d30ae..4c99b4f 100755 --- a/Datatables/Datatable.php +++ b/Datatables/Datatable.php @@ -685,9 +685,12 @@ public function setWhere(QueryBuilder $qb) if (!empty($this->callbacks['WhereCollection'])) { foreach ($this->callbacks['WhereCollection'] as $callback) { $whereCollection = $callback($qb->expr()); - if (!is_array($whereCollection)) + if (!is_array($whereCollection)) { throw new \Exception(sprintf("The function %s must return an array", $callback)); + } + if (sizeof($whereCollection) == 0) continue; + $qb->andWhere($qb->expr()->andX()->addMultiple($whereCollection)); } } @@ -964,8 +967,11 @@ public function getCountAllResults() if (!empty($this->callbacks['WhereCollection']) && $this->hideFilteredCount) { foreach ($this->callbacks['WhereCollection'] as $callback) { $whereCollection = $callback($qb->expr()); - if (!is_array($whereCollection)) + if (!is_array($whereCollection)) { throw new \Exception(sprintf("The function %s must return an array", $callback)); + } + + if (sizeof($whereCollection) == 0) continue; $qb->andWhere($qb->expr()->andX()->addMultiple($whereCollection)); }