diff --git a/README.md b/README.md
index 0bc208458d1ac2b8bf0c65d6b9adf2f4fa0a53a1..ad998f82ece542db49024c68fa6b28f790b05428 100644
--- a/README.md
+++ b/README.md
@@ -772,4 +772,50 @@ PHP Warning:  Uncaught exception 'Storm_Test_ObjectWrapperException' with messag
   [1] =>
   string(6) "juliet"
 }
-```
\ No newline at end of file
+```
+
+
+# Objects Query
+
+Represents an object to collect all clauses, like where order ...
+From Storm_Model_Abstract, displays result on format DB or volatile.
+
+## Storm_Query_Clause
+
+It's represent an sql criteria like "column" = "value"
+With 3 attributes
+ - a key for Storm_Model_Abstract attribut
+ - an operator like "=", ">" ...
+ - a value who can be a string, an array, an other Storm_Query_Clause or null
+
+Examples
+ - equal clause :
+```php
+public static function equal(string $key, string $value) : self
+```
+ - is null clause :
+```php
+public static function isNull(string $key) : self
+```
+
+All clauses must respond to two methods :
+```php
+public function getFormatDb() : string
+
+public function containAttibuteInVolatile(array $model) : bool
+```
+
+All clauses can be created with a static call, that manages the operator value
+
+## Storm_Query
+
+Collect all clauses and can assemble the result on DB format or in Volatile mode
+
+### Storm_Query_CriteriaInterface
+
+Display all singleton basic method to collect clauses
+
+## Testings
+
+All the tests are in Class tests/Storm/Test/LoaderQueryTest.php
+Any new Clause must implements an Db Test and an Volatile Test
\ No newline at end of file
diff --git a/src/Storm/Model/Loader.php b/src/Storm/Model/Loader.php
index 0d7ba1ddf118726444a5cef3015dd232dacb3020..ac40c5ae3d81be540e1a3fc628f4feb98f818199 100644
--- a/src/Storm/Model/Loader.php
+++ b/src/Storm/Model/Loader.php
@@ -90,7 +90,6 @@ class Storm_Model_Loader {
   }
 
 
-
   public static function resetCache() {
     static::$_loader_cache = null;
   }
@@ -144,9 +143,8 @@ class Storm_Model_Loader {
    * @param mixed $select
    * @return array
    */
-  public function findAll($select=null) {
+  public function findAll($select = null) : array {
     return $this->getPersistenceStrategy()->findAll($select);
-
   }
 
 
@@ -161,6 +159,7 @@ class Storm_Model_Loader {
     if (isset($this->_persistence_strategy))
       return $this->_persistence_strategy;
 
+    // Storm_Model_PersistenceStrategy_Db | Storm_Model_PersistenceStrategy_Volatile
     $class_name = 'Storm_Model_PersistenceStrategy_' . static::$_default_strategy;
     return $this->_persistence_strategy = new $class_name($this);
   }
@@ -453,98 +452,55 @@ class Storm_Model_Loader {
   }
 
 
-
   /**
    * @param array $args
    * @return Storm_Model_Abstract
    */
   public function findFirstBy($args) {
-    $args['limit'] = 1;
-    $instances = $this->findAllBy($args);
-
-    if (count($instances) == 0) {
-      return null;
-    }
-    $first_instance = reset($instances);
-    $this->cacheInstance($first_instance);
-    return $first_instance;
-  }
-
-
-  public function fetchAllBy($fields, $params) {
-    return $this->getPersistenceStrategy()->fetchAllBy($fields, $params);
-  }
-
-
-  public function clauseLike(string $key, string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseLike($key, $value);
-  }
-
+    $instances = $this->findAllBy($this->_prepareFirstBy($args));
 
-  public function clauseNotLike(string $key, string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseNotLike($key, $value);
+    return $this->_findFirstFrom($instances);
   }
 
 
-  public function clauseGreater(string $key, string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseGreater($key, $value);
-  }
-
-
-  public function clauseGreaterEqual(string $key, string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseGreaterEqual($key, $value);
-  }
-
-
-  public function clauseLesser(string $key, string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseLesser($key, $value);
-  }
-
-
-  public function clauseLesserEqual(string $key, string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseLesserEqual($key, $value);
-  }
-
-
-  public function clauseIs(string $key) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseIs($key);
-  }
+  protected function _findFirstFrom(array $instances) : ?Storm_Model_Abstract {
+    if (0 === count($instances))
+      return null;
 
+    $first_instance = reset($instances);
+    $this->cacheInstance($first_instance);
 
-  public function clauseNotIs(string $key) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseNotIs($key);
+    return $first_instance;
   }
 
 
-  public function clauseIn(string $key, array $array) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseIn($key, $array);
+  protected function _prepareFirstBy($args) {
+    $args['limit'] = 1;
+    return $args;
   }
 
 
-  public function clauseNotIn(string $key, array $array) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseNotIn($key, $array);
+  public function fetchAllBy($fields, $params) {
+    return $this->getPersistenceStrategy()->fetchAllBy($fields, $params);
   }
 
 
-  public function clauseEqual(string $key,
-                              string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseEqual($key, $value);
+  public function fetchForQuery(Storm_Query $query) : array {
+    return $this->getPersistenceStrategy()->fetchForQuery($query);
   }
 
 
-  public function clauseNotEqual(string $key, string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseNotEqual($key, $value);
+  public function countForQuery(Storm_Query $query) : int {
+    return $this->getPersistenceStrategy()->countForQuery($query);
   }
 
 
-  public function clauseStart(string $key,
-                              string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseStart($key, $value);
+  public function fetchFirstForQuery(Storm_Query $query) : ?Storm_Model_Abstract {
+    return $this->_findFirstFrom($this->fetchForQuery($query->limit(1)));
   }
 
 
-  public function clauseEnd(string $key,
-                            string $value) : Storm_Model_PersistenceStrategy_Clause {
-    return Storm_Model_PersistenceStrategy_Clause::clauseEnd($key, $value);
+  public function greater(string $key, string $value) : Storm_Query_Clause {
+    return Storm_Query_Clause::greater($key, $value);
   }
 }
diff --git a/src/Storm/Model/PersistenceStrategy/Abstract.php b/src/Storm/Model/PersistenceStrategy/Abstract.php
index c99ba05c9a6e30c76e4d37f4f08e4cc79f5390b5..e0d0395f876d1bf51c6558e514a01e4e85ffa912 100644
--- a/src/Storm/Model/PersistenceStrategy/Abstract.php
+++ b/src/Storm/Model/PersistenceStrategy/Abstract.php
@@ -26,20 +26,20 @@ THE SOFTWARE.
 
 
 class Storm_Model_PersistenceStrategy_Abstract  {
-  protected $_loader;
 
+  protected Storm_Model_Loader $_loader;
 
-  public function __construct($loader){
-    $this->_loader=$loader;
+  public function __construct(Storm_Model_Loader $loader) {
+    $this->_loader = $loader;
   }
 
 
-  public function isVolatile() {
+  public function isVolatile() : bool {
     return false;
   }
 
 
-  public function fetchAllBy($fields, $params) {
+  public function fetchAllBy($fields, $params) : array {
     return [];
   }
 
diff --git a/src/Storm/Model/PersistenceStrategy/Clause.php b/src/Storm/Model/PersistenceStrategy/Clause.php
deleted file mode 100644
index b1db63ec7e60f8cde186de2f3f47de72bfce3f7d..0000000000000000000000000000000000000000
--- a/src/Storm/Model/PersistenceStrategy/Clause.php
+++ /dev/null
@@ -1,234 +0,0 @@
-<?php
-/*
-STORM is under the MIT License (MIT)
-
-Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-*/
-
-class Storm_Model_PersistenceStrategy_Clause {
-
-  protected
-    $_key,
-    $_operator,
-    $_value,
-    $_array,
-    $_negated,
-    $_is_array;
-
-  const
-    CLAUSE_WHERE = 'where',
-    CLAUSE_LIKE = 'like',
-    CLAUSE_EQUAL = '=',
-    CLAUSE_IN = 'in',
-    CLAUSE_IS = 'is',
-    CLAUSE_GREATER = '>',
-    CLAUSE_GREATER_EQUAL = '>=',
-    CLAUSE_LESSER = '<',
-    CLAUSE_LESSER_EQUAL = '<=',
-    PERCENT = '%';
-
-
-  public function __construct(string $key, string $operator, $value_or_array) {
-    $this->_negated = false;
-    $this->_key = $key;
-    $this->_operator = $operator;
-    $this->_is_array = is_array($value_or_array);
-
-    if ($this->_is_array)
-      $this->_array = $value_or_array;
-
-    if (!$this->_is_array)
-      $this->_value = $value_or_array;
-  }
-
-
-  public static function clauseIs(string $key) : self {
-    return new static($key, static::CLAUSE_IS, null);
-  }
-
-
-  public static function clauseNotIs(string $key) : self {
-    return static::clauseIs($key)->_setNegated(true);
-  }
-
-
-  public static function clauseIn(string $key, array $array) : self {
-    return new static($key, static::CLAUSE_IN, $array);
-  }
-
-
-  public static function clauseNotIn(string $key, array $array) : self {
-    return static::clauseIn($key, $array)->_setNegated(true);
-  }
-
-
-  public static function clauseEqual(string $key, string $value) : self {
-    return new static($key, static::CLAUSE_EQUAL, $value);
-  }
-
-
-  public static function clauseNotEqual(string $key, string $value) : self {
-    return static::clauseEqual($key, $value)->_setNegated(true);
-  }
-
-
-  public static function clauseLike(string $key, string $value) : self {
-    return new static($key, static::CLAUSE_LIKE, $value);
-  }
-
-
-  public static function clauseNotLike(string $key, string $value) : self {
-    return static::clauseLike($key, $value)->_setNegated(true);
-  }
-
-
-  public static function clauseStart(string $key, string $value) : self {
-    return new static($key, static::CLAUSE_LIKE, $value . static::PERCENT);
-  }
-
-
-  public static function clauseEnd(string $key, string $value) : self {
-    return new static($key, static::CLAUSE_LIKE, static::PERCENT . $value);
-  }
-
-
-  public static function clauseGreater(string $key, string $value) : self {
-    return new static($key, static::CLAUSE_GREATER, $value);
-  }
-
-
-  public static function clauseGreaterEqual(string $key, string $value) : self {
-    return new static($key, static::CLAUSE_GREATER_EQUAL, $value);
-  }
-
-
-  public static function clauseLesser(string $key, string $value) : self {
-    return new static($key, static::CLAUSE_LESSER, $value);
-  }
-
-
-  public static function clauseLesserEqual(string $key, string $value) : self {
-    return new static($key, static::CLAUSE_LESSER_EQUAL, $value);
-  }
-
-
-  public static function newWith(string $key, $value_or_array) : self {
-    $operator = static::CLAUSE_EQUAL;
-    if (static::CLAUSE_WHERE === $key)
-      $operator = static::CLAUSE_WHERE;
-
-    $is_like = ' like' === substr($key, -5);
-    $key = $is_like ? substr($key, 0, strlen($key) - 5) : $key;
-    $negated = (' not' === substr($key, -4));
-    $key = $negated ? substr($key, 0, strlen($key) - 4) : $key;
-
-    if ($is_like)
-      $operator = static::CLAUSE_LIKE;
-
-    if (null === $value_or_array)
-      $operator = static::CLAUSE_IS;
-
-    if (is_array($value_or_array))
-      $operator = static::CLAUSE_IN;
-
-    return (new static($key, $operator, $value_or_array))->_setNegated($negated);
-  }
-
-
-  protected function _setNegated(bool $negated) : self {
-    $this->_negated = $negated;
-    return $this;
-  }
-
-
-  protected function _getValueOrArray() {
-    return $this->_is_array
-      ? ($this->_array ?? [])
-      : ($this->_value ?? '');
-  }
-
-
-  public function getFormatDb($table) : string {
-    if (static::CLAUSE_WHERE === $this->_operator)
-      return '(' . $this->_getValueOrArray() . ')';
-    if (static::CLAUSE_IS === $this->_operator)
-      return $this->_key . ' ' . $this->_getOperator() . ' null';
-
-    return $table->getAdapter()
-                 ->quoteInto($this->_clauseFormatDb(),
-                             $this->_getValueOrArray(), null, null);
-  }
-
-
-  protected function _clauseFormatDb() : string {
-    if (static::CLAUSE_IN === $this->_operator)
-      return $this->_key . ' ' . $this->_getOperator() . ' (?)';
-    if (static::CLAUSE_LIKE === $this->_operator)
-      return $this->_key . ' ' . $this->_getOperator() . ' ?';
-
-    return $this->_key . $this->_getOperator() . '?';
-  }
-
-
-  protected function _getOperator() : string {
-    if (static::CLAUSE_EQUAL === $this->_operator)
-      return ($this->_negated ? '!' : '') . $this->_operator;
-    if (static::CLAUSE_IS === $this->_operator)
-      return $this->_operator . ($this->_negated ? ' not' : '');
-
-    return ($this->_negated ? 'not ' : '') . $this->_operator;
-  }
-
-
-  public function containAttibuteInVolatile($model) : bool {
-    if (!array_key_exists($this->_key, $model))
-      return $this->_negated;
-
-    if ($this->_is_array && 0 === count($this->_getValueOrArray()))
-      throw new Storm_Model_Exception(sprintf('array given for %s is empty',
-                                              $this->_key));
-
-    if ($this->_is_array && in_array($model[$this->_key], $this->_getValueOrArray()))
-      return !$this->_negated;
-
-    if (static::CLAUSE_LIKE === $this->_operator) {
-      $matches = preg_match(('/^' . str_replace('%', '.*',
-                                                $this->_getValueOrArray()) . '$/i'),
-                            $model[$this->_key]);
-      return $this->_negated ? !$matches : $matches;
-    }
-
-    if (static::CLAUSE_GREATER === $this->_operator)
-      return $model[$this->_key] > $this->_getValueOrArray();
-
-    if (static::CLAUSE_GREATER_EQUAL === $this->_operator)
-      return $model[$this->_key] >= $this->_getValueOrArray();
-
-    if (static::CLAUSE_LESSER === $this->_operator)
-      return $model[$this->_key] < $this->_getValueOrArray();
-
-    if (static::CLAUSE_LESSER_EQUAL === $this->_operator)
-      return $model[$this->_key] <= $this->_getValueOrArray();
-
-    return ($this->_negated !== ($model[$this->_key] == $this->_getValueOrArray()));
-  }
-}
diff --git a/src/Storm/Model/PersistenceStrategy/Db.php b/src/Storm/Model/PersistenceStrategy/Db.php
index cc5b23a87b8ec769330a2b810df832c367be01b0..bbc88d469e18b5689b0740e41e7b160673eb9c80 100644
--- a/src/Storm/Model/PersistenceStrategy/Db.php
+++ b/src/Storm/Model/PersistenceStrategy/Db.php
@@ -62,6 +62,7 @@ class Storm_Model_PersistenceStrategy_Db
     return null;
   }
 
+
   public function update($data, $id) {
     return $this->getTable()->update($data, $this->_loader->getIdField(). "='" . $id . "'");
   }
@@ -96,10 +97,11 @@ class Storm_Model_PersistenceStrategy_Db
   }
 
 
-  protected function _generateWhereClauseForKeyAndValue(string $key, $value) : string {
-    $clause = ($value instanceof Storm_Model_PersistenceStrategy_Clause)
+  protected function _generateWhereClauseForKeyAndValue(string $key,
+                                                        $value) : string {
+    $clause = ($value instanceof Storm_Query_Clause)
       ? $value
-      : Storm_Model_PersistenceStrategy_Clause::newWith($key, $value);
+      : Storm_Query_Clause::newWith($key, $value);
 
     return $clause->getFormatDb($this->getTable());
   }
@@ -111,7 +113,25 @@ class Storm_Model_PersistenceStrategy_Db
   }
 
 
-  public function findAll($select=null) {
+  public function fetchForQuery(Storm_Query $query) : array {
+    return $this->findAll($this->_assembleQuery($query));
+  }
+
+
+  public function countForQuery(Storm_Query $query) : int {
+    return $this->_count($this->_assembleQuery($query));
+  }
+
+
+  protected function _assembleQuery(Storm_Query $query) {
+    $select = $this->getTable()->select();
+    $query->assemble($select);
+
+    return $select;
+  }
+
+
+  public function findAll($select = null) : array {
     if (is_string($select))
       return $this->_findAllByString($select);
 
@@ -127,14 +147,14 @@ class Storm_Model_PersistenceStrategy_Db
   }
 
 
-  public function findAllBy($args) {
+  public function findAllBy(array $args = []) {
     if ($select = $this->_generateSelectFor($args))
       return $this->findAll($select);
     return [];
   }
 
 
-  public function fetchAllBy($fields, $params) {
+  public function fetchAllBy($fields, $params) : array {
     if (!$select = $this->_generateSelectFor($params))
       return [];
 
@@ -145,13 +165,20 @@ class Storm_Model_PersistenceStrategy_Db
   }
 
 
-  public function countBy($args) {
+  public function countBy(array $args = []) : int {
     if (!$select = $this->_generateSelectFor($args))
       return 0;
 
+    return $this->_count($select);
+  }
+
+
+  protected function _count($select) : int {
     $select->from($this->getTable(),
                   [sprintf('count(*) as numberof')]);
+
     $rows = $this->getTable()->fetchAll($select)->toArray();
+
     return $rows[0]['numberof'];
   }
 
@@ -169,8 +196,9 @@ class Storm_Model_PersistenceStrategy_Db
   /**
    * @see findAllBy
    */
-  public function _generateSelectFor($args) {
-    if (array_key_exists('role', $args) && array_key_exists('model', $args)) {
+  public function _generateSelectFor(array $args = []) {
+    if (array_key_exists('role', $args)
+        && array_key_exists('model', $args)) {
       $model = $args['model'];
       $role = $args['role'];
       unset($args['model']);
@@ -185,26 +213,32 @@ class Storm_Model_PersistenceStrategy_Db
     else
       $select = $this->getTable()->select();
 
-    foreach ($args as $field => $value) {
-      if (in_array($field, ['order', 'limit', 'where'], true)) {
-        $select->$field($value);
-        continue;
-      }
+    foreach ($args as $field => $value)
+      $this->_addInSelect($select, $field, $value);
+
+    return $select;
+  }
+
 
-      if (in_array($field, ['limitPage', 'scope'], true)) {
-        $this->$field($select, $value);
-        continue;
-      }
+  protected function _addInSelect($select, string $field, $value_or_clause) : self {
+    if (in_array($field, ['order', 'limit', 'where'], true)) {
+      $select->$field($value_or_clause);
+      return $this;
+    }
 
-      if ('group_by' === $field) {
-        $select->group($value);
-        continue;
-      }
+    if (in_array($field, ['limitPage', 'scope'], true)) {
+      $this->$field($select, $value_or_clause);
+      return $this;
+    }
 
-      $select->where($this->_generateWhereClauseForKeyAndValue($field, $value));
+    if ('group_by' === $field) {
+      $select->group($value_or_clause);
+      return $this;
     }
 
-    return $select;
+    $select->where($this->_generateWhereClauseForKeyAndValue($field,
+                                                             $value_or_clause));
+    return $this;
   }
 
 
diff --git a/src/Storm/Model/PersistenceStrategy/Volatile.php b/src/Storm/Model/PersistenceStrategy/Volatile.php
index 30344a661493dd7353604c80b4449a554f89983f..260c04e95272dfb8c0de6d140bf29428332f613e 100644
--- a/src/Storm/Model/PersistenceStrategy/Volatile.php
+++ b/src/Storm/Model/PersistenceStrategy/Volatile.php
@@ -1,37 +1,37 @@
 <?php
 /*
-STORM is under the MIT License (MIT)
+  STORM is under the MIT License (MIT)
 
-Copyright (c) 2010-2011 Agence Française Informatique http://www.afi-sa.fr
+  Copyright (c) 2010-2011 Agence Française Informatique http://www.afi-sa.fr
 
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
 
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
 
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
 
 */
 
-class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceStrategy_Abstract {
+class Storm_Model_PersistenceStrategy_Volatile
+  extends Storm_Model_PersistenceStrategy_Abstract {
+
   protected
     $_instances = [],
-    $desc_order=false,
-    $special_select_fields = ['order', 'limit', 'limitpage', 'where', 'group_by'],
+    $desc_order = false,
     $_table;
 
-
   public function getTable() {
     return $this->_table;
   }
@@ -43,36 +43,46 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
   }
 
 
-  public function findAll($select = null) {
-    if (null === $filtered_select = $this->extractRoleAndModel(is_array($select)
-                                                               ? array_change_key_case($select)
-                                                               : []))
-      return [];
+  /**
+   * @param $select null | string | array
+   */
+  public function findAll($select = null) : array {
+    $select = $this->_extractRoleAndModel($select);
 
-    $group_by = $filtered_select['group_by'] ?? '';
-    $order = $filtered_select['order'] ?? '';
-    $limit = $filtered_select['limit'] ?? '';
+    return null === $select ? [] : $this->_findAll($select, null);
+  }
 
-    $page_size=0;
-    if (isset($filtered_select['limitpage'])) {
-      list($page, $page_size) = $filtered_select['limitpage'];
-      if ($page > 0) $page -= 1;
-    }
 
-    foreach($this->special_select_fields as $field)
-      unset($filtered_select[$field]);
+  /**
+   * @param $select null | string | array
+   */
+  protected function _findAll($select, ?Storm_Query $query) : array {
+    $group_by = $this->_getGroupBy($select, $query);
+    $order = $this->_getOrder($select);
+    $limit = $this->_getLimit($select, $query);
+    $limit_page = $this->_getLimitPage($select, $query);
+
+    $page_size = 0;
+    if ($limit_page) {
+      list($page, $page_size) = $limit_page;
+      if ($page > 0)
+        $page -= 1;
+    }
 
     $values = [];
-    $this->_allMatchingInstancesDo($filtered_select,
+    $this->_allMatchingInstancesDo($select,
+                                   $query,
                                    function($model) use (&$values) {
                                      $values []= $model;
                                    });
 
     $values = $this->groupBy($values, $group_by);
-    $values = $this->ordered($values, $order);
+    $values = $query
+      ? $query->orderVolatile($values)
+      : $this->ordered($values, $order);
     $values = $this->limited($values, $limit);
 
-    if ($page_size>0)
+    if ($page_size > 0)
       $values = array_slice($values, $page * $page_size, $page_size);
 
     return array_map([$this->_loader, 'newFromRow'],
@@ -80,74 +90,84 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
   }
 
 
-  public function fetchAllBy($fields, $params) {
+  public function fetchAllBy($fields, $params) : array {
     return array_map(function($model) use($fields)
-                     {
-                       $keys = array_map(function($field)
-                                         {
-                                           $parts = explode(' as ', $field);
-                                           return current($parts);
-                                         },
-                                         $fields);
-
-                       $aliases = array_map(function($field)
-                                            {
-                                              $parts = explode(' as ', $field);
-                                              return isset($parts[1]) ? $parts[1] : current($parts);
-                                            },
-                                            $fields);
-
-                       $map = array_combine($keys, $aliases);
-
-                       $filtered = array_filter($model->getRawAttributes(),
-                                                function($key) use ($keys)
-                                                {
-                                                  return in_array($key, $keys);
-                                                },
-                                                ARRAY_FILTER_USE_KEY);
-
-                       $row = [];
-                       foreach($filtered as $k => $v)
-                         $row[$map[$k]] = $v;
-
-                       return $row;
-                     },
+    {
+      $keys = array_map(fn($field) => current(explode(' as ', $field)),
+                        $fields);
+
+      $aliases = array_map(function($field)
+      {
+        $parts = explode(' as ', $field);
+        return isset($parts[1]) ? $parts[1] : current($parts);
+      },
+                           $fields);
+
+      $map = array_combine($keys, $aliases);
+
+      $filtered = array_filter($model->getRawAttributes(),
+                               fn($key) => in_array($key, $keys),
+                               ARRAY_FILTER_USE_KEY);
+
+      $row = [];
+      foreach($filtered as $k => $v)
+        $row[$map[$k]] = $v;
+
+      return $row;
+    },
                      $this->findAll($params));
   }
 
 
-  protected function extractRoleAndModel($select) {
-    if (array_key_exists('role', $select) && array_key_exists('model', $select)) {
+  public function fetchForQuery(Storm_Query $query) : array {
+    return $this->_findAll([], $query);
+  }
+
+
+  public function countForQuery(Storm_Query $query) : int {
+    return sizeof($this->fetchForQuery($query));
+  }
+
+
+  protected function _extractRoleAndModel($select) : ?array {
+    $select = is_array($select)
+      ? array_change_key_case($select)
+      : [];
+
+    if (array_key_exists('role', $select)
+        && array_key_exists('model', $select)) {
       $model = $select['model'];
       $role = $select['role'];
       unset($select['model']);
       unset($select['role']);
 
-      if ($model->isNew()) return null;
+      // Warning, this return null is necessary to early exit search mode
+      if ($model->isNew())
+        return null;
 
       $field = $this->_loader->getIdFieldForDependent($role);
-      $select[$field]=$model->getId();
+      $select[$field] = $model->getId();
     }
 
     if (array_key_exists('scope', $select)) {
-      $select = array_merge($select, array_change_key_case($select['scope']));
+      $select = array_merge($select,
+                            array_change_key_case($select['scope']));
       unset($select['scope']);
     }
+
     return $select;
   }
 
 
-
   protected function getInstancesArray() : array {
     return
       array_values(
-        array_map(
-          function($model) {return $model->getRawAttributes();},
-          $this->_instances));
+                   array_map(fn($model) => $model->getRawAttributes(),
+                             $this->_instances));
   }
 
 
-  public function findAllBy($args) : array {
+  public function findAllBy(array $args = []) : array {
     return $this->findAll($args);
   }
 
@@ -155,13 +175,11 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
   protected function compareFunction($order) {
     $first_clause = Storm_Model_PersistenceStrategy_Volatile_OrderClause::parseOrderClause($order);
 
-    return function($a, $b) use ($first_clause) {
-      return $first_clause->compare($a, $b);
-    };
+    return fn($a, $b) => $first_clause->compare($a, $b);
   }
 
 
-  public function ordered($result, $select) {
+  public function ordered(array $result, $select) : array {
     if (! ($select && $result) )
       return $result;
 
@@ -173,8 +191,8 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
   }
 
 
-  public function limited($result, $limit) {
-    if ('' == $limit)
+  public function limited(array $result, string $limit) : array {
+    if (!$limit)
       return $result;
 
     if (false === strpos($limit, ','))
@@ -211,16 +229,16 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
 
 
   public function containsAttribute(array $model, string $key, $value) : bool {
-    $clause = ($value instanceof Storm_Model_PersistenceStrategy_Clause)
+    $clause = ($value instanceof Storm_Query_Clause)
       ? $value
       : null;
 
     if (!$clause && preg_match('/left\((.+),(.+)\)/', $key, $matches))
-      $clause = Storm_Model_PersistenceStrategy_Clause::clauseLike(trim($matches[1]),
-                                                                   (substr($value, 0, (int)$matches[2]) . '%'));
+      $clause = Storm_Query_Clause::like(trim($matches[1]),
+                                         (substr($value, 0, (int)$matches[2]) . '%'));
 
     if (!$clause)
-      $clause = Storm_Model_PersistenceStrategy_Clause::newWith($key, $value);
+      $clause = Storm_Query_Clause::newWith($key, $value);
 
     return $clause->containAttibuteInVolatile($model);
   }
@@ -276,6 +294,7 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
 
   public function updateAll(array $where, array $set_values) : int {
     return $this->_allMatchingInstancesDo($where,
+                                          null,
                                           function($model) use ($set_values) {
                                             $this->_instances[$model['id']]->initializeAttributes($set_values);
                                           });
@@ -284,6 +303,7 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
 
   public function deleteBy(array $clauses) : int {
     return $this->_allMatchingInstancesDo($clauses,
+                                          null,
                                           function($model) {
                                             unset($this->_instances[$model['id']]);
                                           },
@@ -291,11 +311,14 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
   }
 
 
-  protected function _allMatchingInstancesDo(array $clauses, callable $callback) : int {
-    $matching_instances = array_filter($this->getInstancesArray(),
-                                       function ($model) use ($clauses) {
-                                         return $this->_modelMatchesClauses($model, $clauses);
-                                       });
+  protected function _allMatchingInstancesDo(array $clauses,
+                                             ?Storm_Query $query,
+                                             callable $callback) : int {
+    $filter_callback = $query
+      ? fn($model) => $this->_modelMatchesQuery($model, $query)
+      : fn($model) => $this->_modelMatchesClauses($model, $clauses);
+
+    $matching_instances = array_filter($this->getInstancesArray(), $filter_callback);
 
     return count(array_map($callback, $matching_instances));
   }
@@ -306,6 +329,11 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
   }
 
 
+  protected function _modelMatchesQuery(array $model, Storm_Query $query) : bool {
+    return $query->containsAllAttributes($model);
+  }
+
+
   public function countBy(array $args) : int {
     return sizeof($this->findAll($args));
   }
@@ -314,4 +342,43 @@ class Storm_Model_PersistenceStrategy_Volatile  extends Storm_Model_PersistenceS
   public function isVolatile() : bool {
     return true;
   }
+
+
+  protected function _getLimit(array &$select, ?Storm_Query $query) : string {
+    if ($query)
+      return $query->getLimitValue();
+
+    $clause = Storm_Query_Clause::limit($select['limit'] ?? 0);
+
+    unset($select['limit']);
+    return $clause->getValue();
+  }
+
+
+  protected function _getLimitPage(array &$select, ?Storm_Query $query) : array {
+    if ($query)
+      return $query->getLimitPageValue();
+
+    $clause = Storm_Query_Clause::limitPage($select['limitpage'] ?? []);
+
+    unset($select['limitpage']);
+    return $clause->getValue();
+  }
+
+
+  protected function _getOrder(array &$select) {
+    $order = $select['order'] ?? '';
+    unset($select['order']);
+    return $order;
+  }
+
+
+  protected function _getGroupBy(array &$select, ?Storm_Query $query) : string {
+    if ($query)
+      return $query->getGroupByValue();
+
+    $group_by = $select['group_by'] ?? '';
+    unset($select['group_by']);
+    return $group_by;
+  }
 }
diff --git a/src/Storm/Model/PersistenceStrategy/Volatile/OrderClause.php b/src/Storm/Model/PersistenceStrategy/Volatile/OrderClause.php
index 7f65cbf5ec42648f2108870587e6c2140221537b..4fb492794982317b1de02acb34084928d9b8d7c1 100644
--- a/src/Storm/Model/PersistenceStrategy/Volatile/OrderClause.php
+++ b/src/Storm/Model/PersistenceStrategy/Volatile/OrderClause.php
@@ -1,26 +1,26 @@
 <?php
 /*
-STORM is under the MIT License (MIT)
+  STORM is under the MIT License (MIT)
 
-Copyright (c) 2010-2011 Agence Française Informatique http://www.afi-sa.fr
+  Copyright (c) 2010-2011 Agence Française Informatique http://www.afi-sa.fr
 
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
 
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
 
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
 
 */
 
@@ -34,12 +34,13 @@ class Storm_Model_PersistenceStrategy_Volatile_OrderClause {
     $clauses = array_reverse(array_filter(explode(',', strtolower($order))));
 
     $first_clause = new static(array_shift($clauses));
-    foreach($clauses as $clause) {
+    foreach($clauses as $clause)
       $first_clause = new static($clause, $first_clause);
-    }
+
     return $first_clause;
   }
 
+
   public function __construct($order, $next_clause = null) {
     $description = explode(' ', trim($order));
 
@@ -60,21 +61,11 @@ class Storm_Model_PersistenceStrategy_Volatile_OrderClause {
     }
 
     $result = (is_int($first))
-      ? $this->int_compare((int)$first, (int)$last)
-      : $this->string_compare($first, $last);
+      ? ($first - (int)$last)
+      : strcmp($first, $last);
 
     return (($result === 0) && $this->_next_clause)
       ? $this->_next_clause->compare($a, $b)
       : $result;
   }
-
-
-  public function int_compare($a , $b)  {
-    return ($a == $b) ? 0 : (($a < $b) ? -1 : 1);
-  }
-
-
-  public function string_compare($a, $b){
-    return strcmp($a, $b);
-  }
 }
diff --git a/src/Storm/Query.php b/src/Storm/Query.php
new file mode 100644
index 0000000000000000000000000000000000000000..bac1b191e5df2745da62837fab5d5bc541a56ab7
--- /dev/null
+++ b/src/Storm/Query.php
@@ -0,0 +1,360 @@
+<?php
+/*
+  STORM is under the MIT License (MIT)
+
+  Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
+
+*/
+
+
+class Storm_Query implements Storm_Query_CriteriaInterface {
+
+  protected array $_orders;
+  protected Storm_Model_Loader $_loader;
+  protected Storm_Query_CriteriaInterface $_criteria;
+  protected ?Storm_Query_Clause $_clause_limit;
+  protected ?Storm_Query_Clause $_clause_limit_page;
+  protected ?Storm_Query_Clause $_clause_group_by;
+
+  protected function __construct(Storm_Model_Loader $loader) {
+    $this->_loader = $loader;
+    $this->_orders = [];
+    $this->_clause_limit = null;
+    $this->_clause_limit_page = null;
+    $this->_clause_group_by = null;
+    $this->_criteria = new Storm_Query_Criteria;
+  }
+
+
+  public static function from(string $loader_class) : self {
+    return new static($loader_class::getLoader());
+  }
+
+
+  /**
+   * @param $limit int | string
+   */
+  public function limit($limit) : self {
+    $this->_clause_limit = Storm_Query_Clause::limit($limit);
+    return $this;
+  }
+
+
+  /**
+   * @param $range array | int | string
+   */
+  public function limit_page($range) : self {
+    $this->_clause_limit_page = Storm_Query_Clause::limitPage($range);
+    return $this;
+  }
+
+
+  public function group(string $value) : self {
+    $this->_clause_group_by = Storm_Query_Clause::group($value);
+    return $this;
+  }
+
+
+  /**
+   * @param $key_or_clause string|Storm_Query_MatchRating
+   */
+  public function order($key_or_clause) : self {
+    $this->_orders [] = $this->_order($key_or_clause)
+      ->setOrder(false);
+    return $this;
+  }
+
+
+  /**
+   * @param $key_or_clause string|Storm_Query_MatchRating
+   */
+  public function order_desc($key_or_clause) : self {
+    $this->_orders [] = $this->_order($key_or_clause)
+      ->setOrder(true);
+    return $this;
+  }
+
+
+  /**
+   * @param $key_or_clause string|Storm_Query_MatchRating
+   */
+  protected function _order($key_or_clause) : Storm_Query_Clause {
+    if ($key_or_clause instanceof Storm_Query_MatchRating)
+      return Storm_Query_Clause::order($key_or_clause->getKey(),
+                                       Storm_Query_Clause::match($key_or_clause));
+
+    return Storm_Query_Clause::order($key_or_clause);
+  }
+
+
+  public function fetchAll() : array {
+    return $this->_loader->fetchForQuery($this);
+  }
+
+
+  public function count() : int {
+    return $this->_loader->countForQuery($this);
+  }
+
+
+  public function fetchFirst() : ?Storm_Model_Abstract {
+    return $this->_loader->fetchFirstForQuery($this);
+  }
+
+
+  /** interface Storm_Query_CriteriaInterface */
+
+  public function beOr() : self {
+    $this->_criteria->beOr();
+    return $this;
+  }
+
+
+  public function or(Storm_Query_CriteriaInterface $criteria) : self {
+    $this->_criteria->or($criteria);
+    return $this;
+  }
+
+
+  public function and(Storm_Query_CriteriaInterface $criteria) : self {
+    $this->_criteria->and($criteria);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function eq(string $key, $value) : self {
+    $this->_criteria->eq($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function not_eq(string $key, $value) : self {
+    $this->_criteria->not_eq($key, $value);
+    return $this;
+  }
+
+
+  public function like(string $key, string $value) : self {
+    $this->_criteria->like($key, $value);
+    return $this;
+  }
+
+
+  public function not_like(string $key, string $value) : self {
+    $this->_criteria->not_like($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function gt(string $key, $value) : self {
+    $this->_criteria->gt($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function gt_eq(string $key, $value) : self {
+    $this->_criteria->gt_eq($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function lt(string $key, $value) : self {
+    $this->_criteria->lt($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function lt_eq(string $key, $value) : self {
+    $this->_criteria->lt_eq($key, $value);
+    return $this;
+  }
+
+
+  public function is_null(string $key) : self {
+    $this->_criteria->is_null($key);
+    return $this;
+  }
+
+
+  public function not_is_null(string $key) : self {
+    $this->_criteria->not_is_null($key);
+    return $this;
+  }
+
+
+  public function in(string $key, array $array) : self {
+    $this->_criteria->in($key, $array);
+    return $this;
+  }
+
+
+  public function not_in(string $key, array $array) : self {
+    $this->_criteria->not_in($key, $array);
+    return $this;
+  }
+
+
+  public function start(string $key, string $value) : self {
+    $this->_criteria->start($key, $value);
+    return $this;
+  }
+
+
+  public function end(string $key, string $value) : self {
+    $this->_criteria->end($key, $value);
+    return $this;
+  }
+
+
+  public function match(Storm_Query_MatchBoolean $match) : self {
+    $this->_criteria->match($match);
+    return $this;
+  }
+
+
+  /** DB management */
+
+  /**
+   * @param $select Zend_Db_Table_Select|Storm_Test_ObjectWrapper
+   */
+  public function assemble(object $select) : self {
+    $sql = new Storm_Query_Sql;
+    $this->_criteria->assemble($sql);
+    if ($where = $sql->implode($this->_criteria->separator()))
+      $select->where($where);
+
+    foreach ($this->_orders as $order)
+      $order->assemble($select);
+
+    if ($this->_clause_limit)
+      $this->_clause_limit->assemble($select);
+
+    if ($this->_clause_limit_page)
+      $this->_clause_limit_page->assemble($select);
+
+    if ($this->_clause_group_by)
+      $this->_clause_group_by->assemble($select);
+
+    return $this;
+  }
+
+
+  /** Volatile management */
+
+  public function orderVolatile(array $models) : array {
+    return (new Storm_Query_Order(array_reverse($this->_orders)))
+      ->compareVolatile($models);
+  }
+
+
+  public function containsAllAttributes(array $model) : bool {
+    return $this->_criteria->containsAllAttributes($model);
+  }
+
+
+  public function getLimitValue() : string {
+    return $this->_clause_limit
+      ? $this->_clause_limit->getValue()
+      : '';
+  }
+
+
+  public function getLimitPageValue() : array {
+    return $this->_clause_limit_page
+      ? $this->_clause_limit_page->getValue()
+      : [];
+  }
+
+
+  public function getGroupByValue() : string {
+    return $this->_clause_group_by
+      ? $this->_clause_group_by->getValue()
+      : '';
+  }
+}
+
+
+
+
+class Storm_Query_Order {
+
+  protected ?Storm_Query_Order $_next_query;
+  protected ?Storm_Query_Clause $_clause;
+  protected int $_position;
+
+  public function __construct(array $orders, ?int $position = 0) {
+    $this->_position = $position++;
+    $this->_clause = array_shift($orders);
+
+    $this->_next_query = ($orders
+                          ? (new Storm_Query_Order($orders, $position))
+                          : null);
+  }
+
+
+  public function getNextQuery() : ?Storm_Query_Order {
+    if (!($next_query = $this->_next_query))
+      return null;
+
+    return $next_query->getClause()
+      ? $next_query
+      : null;
+  }
+
+
+  public function getClause() : ?Storm_Query_Clause {
+    return $this->_clause;
+  }
+
+
+  public function getPosition() : int {
+    return 10 ** $this->_position;
+  }
+
+
+  public function compareVolatile(array $models) : array {
+    if (!$models)
+      return $models;
+
+    if ($clause = $this->getClause())
+      usort($models, fn($a, $b) => $clause->compare($this, $a, $b));
+
+    return $models;
+  }
+}
diff --git a/src/Storm/Query/Clause.php b/src/Storm/Query/Clause.php
new file mode 100644
index 0000000000000000000000000000000000000000..442b565306dd95d9f47d34a133a4d658c641aadd
--- /dev/null
+++ b/src/Storm/Query/Clause.php
@@ -0,0 +1,507 @@
+<?php
+/*
+STORM is under the MIT License (MIT)
+
+Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+
+
+class Storm_Query_Clause {
+
+  protected string $_key;
+  protected string $_operator;
+  protected bool $_negated;
+  protected $_value;
+
+  const
+    CLAUSE_WHERE = 'where',
+    CLAUSE_LIKE = 'like',
+    CLAUSE_EQUAL = '=',
+    CLAUSE_IN = 'in',
+    CLAUSE_IS = 'is',
+    CLAUSE_GREATER = '>',
+    CLAUSE_GREATER_EQUAL = '>=',
+    CLAUSE_LESSER = '<',
+    CLAUSE_LESSER_EQUAL = '<=',
+    CLAUSE_MATCH = 'MATCH',
+    CLAUSE_LIMIT = 'limit',
+    CLAUSE_LIMIT_PAGE = 'limitPage',
+    CLAUSE_ORDER_BY = 'order by',
+    CLAUSE_GROUP_BY = 'group by',
+    PERCENT = '%';
+
+  const CLAUSE_IMPLEMENTATIONS =
+    [
+     self::CLAUSE_LIKE => Storm_Query_ClauseLike::class,
+     self::CLAUSE_EQUAL => Storm_Query_ClauseEqual::class,
+     self::CLAUSE_IN => Storm_Query_ClauseIn::class,
+     self::CLAUSE_IS => Storm_Query_ClauseIsNull::class,
+     self::CLAUSE_GREATER => Storm_Query_ClauseGreater::class,
+     self::CLAUSE_GREATER_EQUAL => Storm_Query_ClauseGreaterEqual::class,
+     self::CLAUSE_LESSER => Storm_Query_ClauseLesser::class,
+     self::CLAUSE_LESSER_EQUAL => Storm_Query_ClauseLesserEqual::class,
+     self::CLAUSE_MATCH => Storm_Query_Clause_Match::class,
+     self::CLAUSE_LIMIT => Storm_Query_ClauseLimit::class,
+     self::CLAUSE_LIMIT_PAGE => Storm_Query_ClauseLimitPage::class,
+     self::CLAUSE_ORDER_BY => Storm_Query_Clause_OrderBy::class,
+     self::CLAUSE_GROUP_BY => Storm_Query_ClauseGroupBy::class,
+     self::CLAUSE_WHERE => Storm_Query_ClauseWhere::class
+    ];
+
+  /**
+   * @param $value null | string | Storm_Query_Clause
+   */
+  public function __construct(string $key, string $operator, $value) {
+    $this->_negated = false;
+    $this->_key = $key;
+    $this->_operator = $operator;
+    $this->_value = $value;
+
+    $this->_prepareValue();
+  }
+
+
+  protected function _prepareValue() : self {
+    return $this;
+  }
+
+
+  public static function newFor(string $key,
+                                string $operator,
+                                $value) : self {
+    $class = static::CLAUSE_IMPLEMENTATIONS[$operator];
+    return new $class($key, $operator, $value);
+  }
+
+
+  public static function newWith(string $key, $value) : self {
+    $operator = static::CLAUSE_EQUAL;
+    if (static::CLAUSE_WHERE === $key)
+      $operator = static::CLAUSE_WHERE;
+
+    $is_like = ' like' === substr($key, -5);
+    $key = $is_like ? substr($key, 0, strlen($key) - 5) : $key;
+    $negated = (' not' === substr($key, -4));
+    $key = $negated ? substr($key, 0, strlen($key) - 4) : $key;
+
+    if ($is_like)
+      $operator = static::CLAUSE_LIKE;
+
+    if (null === $value)
+      $operator = static::CLAUSE_IS;
+
+    if (is_array($value))
+      $operator = static::CLAUSE_IN;
+
+    return (static::newFor($key, $operator, $value))->setNegated($negated);
+  }
+
+
+  public static function isNull(string $key) : self {
+    return static::newFor($key, static::CLAUSE_IS, null);
+  }
+
+
+  public static function in(string $key, array $array) : self {
+    return static::newFor($key, static::CLAUSE_IN, $array);
+  }
+
+
+  public static function equal(string $key, $value) : self {
+    return static::newFor($key, static::CLAUSE_EQUAL, $value);
+  }
+
+
+  public static function like(string $key, string $value) : self {
+    return static::newFor($key, static::CLAUSE_LIKE, $value);
+  }
+
+
+  public static function start(string $key, string $value) : self {
+    return static::newFor($key, static::CLAUSE_LIKE, $value . static::PERCENT);
+  }
+
+
+  public static function end(string $key, string $value) : self {
+    return static::newFor($key, static::CLAUSE_LIKE, static::PERCENT . $value);
+  }
+
+
+  public static function greater(string $key, $value) : self {
+    return static::newFor($key, static::CLAUSE_GREATER, $value);
+  }
+
+
+  public static function greaterEqual(string $key, $value) : self {
+    return static::newFor($key, static::CLAUSE_GREATER_EQUAL, $value);
+  }
+
+
+  public static function lesser(string $key, $value) : self {
+    return static::newFor($key, static::CLAUSE_LESSER, $value);
+  }
+
+
+  public static function lesserEqual(string $key, $value) : self {
+    return static::newFor($key, static::CLAUSE_LESSER_EQUAL, $value);
+  }
+
+
+  public static function match(Storm_Query_MatchRating $match) : self {
+    return static::newFor($match->getKey(), static::CLAUSE_MATCH, $match);
+  }
+
+
+  /**
+   * @param $limit int | string
+   */
+  public static function limit($limit) : self {
+    return static::newFor(static::CLAUSE_LIMIT, static::CLAUSE_LIMIT, $limit);
+  }
+
+
+  /**
+   * @param $range array | int | string
+   */
+  public static function limitPage($range) : self {
+    return static::newFor(static::CLAUSE_LIMIT_PAGE,
+                          static::CLAUSE_LIMIT_PAGE,
+                          $range);
+  }
+
+
+  public static function group(string $value) : self {
+    return static::newFor(static::CLAUSE_GROUP_BY, static::CLAUSE_GROUP_BY, $value);
+  }
+
+
+  public static function order(string $key,
+                               ?Storm_Query_Clause $clause = null) : self {
+    return static::newFor($key, static::CLAUSE_ORDER_BY, $clause);
+  }
+
+
+  public function setNegated(bool $negated) : self {
+    $this->_negated = $negated;
+    return $this;
+  }
+
+
+  public function getValue() {
+    return $this->_value;
+  }
+
+
+  public function assemble($select) : self {
+    return $this;
+  }
+
+
+  public function getFormatDb() : string {
+    if (static::CLAUSE_WHERE === $this->_operator)
+      return '(' . $this->getValue() . ')';
+
+    return Zend_Db_Table_Abstract::getDefaultAdapter()
+      ->quoteInto($this->_clauseFormatDb(),
+                  $this->getValue(), null, null);
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    if (!$this->_existKeyInModel($model))
+      return $this->_negated;
+
+    return ($this->_negated !== ($model[$this->_key] == $this->getValue()));
+  }
+
+
+  protected function _clauseFormatDb() : string {
+    return $this->_key . $this->_getOperator() . '?';
+  }
+
+
+  protected function _getOperator() : string {
+    return ($this->_negated ? 'not ' : '') . $this->_operator;
+  }
+
+
+  protected function _existKeyInModel(array $model) : bool {
+    return array_key_exists($this->_key, $model);
+  }
+}
+
+
+
+
+class Storm_Query_ClauseLike extends Storm_Query_Clause {
+
+  protected function _prepareValue() : self {
+    $this->_value = (string)$this->_value;
+
+    return $this;
+  }
+
+
+  protected function _clauseFormatDb() : string {
+    return $this->_key . ' ' . $this->_getOperator() . ' ?';
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    if (!$this->_existKeyInModel($model))
+      return $this->_negated;
+
+    $matches = preg_match(('/^' . str_replace('%', '.*',
+                                              $this->getValue()) . '$/i'),
+                          $model[$this->_key]);
+
+    return $this->_negated ? !$matches : $matches;
+  }
+}
+
+
+
+
+class Storm_Query_ClauseEqual extends Storm_Query_Clause {
+
+  protected function _getOperator() : string {
+    return ($this->_negated ? '!' : '') . $this->_operator;
+  }
+}
+
+
+
+
+class Storm_Query_ClauseIn extends Storm_Query_Clause {
+
+  protected function _prepareValue() : self {
+    if (!is_array($this->_value))
+      $this->_value = [];
+
+    return $this;
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    if (!$this->_existKeyInModel($model))
+      return $this->_negated;
+
+    if (0 === count($this->getValue()))
+      throw new Storm_Model_Exception(sprintf('array given for %s is empty',
+                                              $this->_key));
+
+    return in_array($model[$this->_key], $this->getValue())
+      ? !$this->_negated
+      : $this->_negated;
+  }
+
+
+  protected function _clauseFormatDb() : string {
+    return $this->_key . ' ' . $this->_getOperator() . ' (?)';
+  }
+}
+
+
+
+
+class Storm_Query_ClauseIsNull extends Storm_Query_Clause {
+
+  protected function _prepareValue() : self {
+    $this->_value = null;
+
+    return $this;
+  }
+
+
+  public function getFormatDb() : string {
+    return $this->_key . ' ' . $this->_getOperator() . ' null';
+  }
+
+
+  protected function _getOperator() : string {
+    return $this->_operator . ($this->_negated ? ' not' : '');
+  }
+}
+
+
+
+
+class Storm_Query_ClauseGreater extends Storm_Query_Clause {
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    if (!$this->_existKeyInModel($model))
+      return false;
+
+    return $model[$this->_key] > $this->getValue();
+  }
+}
+
+
+
+
+class Storm_Query_ClauseGreaterEqual extends Storm_Query_Clause {
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    if (!$this->_existKeyInModel($model))
+      return false;
+
+    return $model[$this->_key] >= $this->getValue();
+  }
+}
+
+
+
+
+class Storm_Query_ClauseLesser extends Storm_Query_Clause {
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    if (!$this->_existKeyInModel($model))
+      return false;
+
+    return $model[$this->_key] < $this->getValue();
+  }
+}
+
+
+
+
+class Storm_Query_ClauseLesserEqual extends Storm_Query_Clause {
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    if (!$this->_existKeyInModel($model))
+      return false;
+
+    return $model[$this->_key] <= $this->getValue();
+  }
+}
+
+
+
+
+class Storm_Query_ClauseLimit extends Storm_Query_Clause {
+
+  protected function _prepareValue() : self {
+    $this->_value = (string)$this->_value;
+
+    return $this;
+  }
+
+
+  public function assemble($select) : self {
+    if ($this->getValue())
+      $select->limit($this->getValue());
+
+    return $this;
+  }
+
+
+  public function getFormatDb() : string {
+    return '';
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    return true;
+  }
+}
+
+
+
+
+class Storm_Query_ClauseLimitPage extends Storm_Query_Clause {
+
+  protected function _prepareValue() : self {
+    if (!is_array($this->_value))
+      $this->_value = [(int)$this->_value];
+
+    $limit = (int)($this->_value[0] ?? 0);
+    $offset = (int)($this->_value[1] ?? 0);
+    $this->_value = [$limit, $offset];
+
+    return $this;
+  }
+
+
+  public function assemble($select) : self {
+    $range = $this->getValue();
+
+    if ($range[1] > 0)
+      $select->limitPage($range[0], $range[1]);
+
+    return $this;
+  }
+
+
+  public function getFormatDb() : string {
+    return '';
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    return true;
+  }
+}
+
+
+
+
+class Storm_Query_ClauseGroupBy extends Storm_Query_Clause {
+
+  protected function _prepareValue() : self {
+    $this->_value = (string)$this->_value;
+
+    return $this;
+  }
+
+
+  public function assemble($select) : self {
+    $select->group($this->getValue());
+    return $this;
+  }
+
+
+  public function getFormatDb() : string {
+    return '';
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    return true;
+  }
+}
+
+
+
+
+class Storm_Query_ClauseWhere extends Storm_Query_Clause {
+
+  protected function _prepareValue() : self {
+    $this->_value = (string)$this->_value;
+
+    return $this;
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    return true;
+  }
+}
diff --git a/src/Storm/Query/Clause/Match.php b/src/Storm/Query/Clause/Match.php
new file mode 100644
index 0000000000000000000000000000000000000000..f7ce65cbe45a59bc62cff3492aec8a26d64f12a8
--- /dev/null
+++ b/src/Storm/Query/Clause/Match.php
@@ -0,0 +1,110 @@
+<?php
+/*
+STORM is under the MIT License (MIT)
+
+Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+
+
+class Storm_Query_Clause_Match extends Storm_Query_Clause {
+
+  public function getFormatDb() : string {
+    if (!$value = $this->getValue())
+      return '';
+
+    $content = '';
+    foreach ($value->getTerms() as $key => $term)
+      $content = implode(' ',
+                         array_filter([$content,
+                                       $term->getFormatDb($value->isStrict())]));
+
+    $against = sprintf('AGAINST(%s%s)',
+                       Zend_Db_Table_Abstract::getDefaultAdapter()
+                       ->quoteInto('?', $content),
+                       ($value->isBooleanMode() ? ' IN BOOLEAN MODE' : ''));
+
+    return $this->_getOperator() . ' ' . $against;
+  }
+
+
+  public function compare(array $a, array $b) : int {
+    return ($this->_compare($this->_getContents($a))
+            - $this->_compare($this->_getContents($b)));
+  }
+
+
+  protected function _compare(string $contents) : int {
+    if (!$value = $this->getValue())
+      return 0;
+
+    $compare = 0;
+
+    foreach ($value->getTerms() as $term) {
+      $new_compare = $term->getCompareValue($contents);
+      if ($value->isStrict() && 0 === $new_compare)
+        return 0;
+
+      $compare += $term->getCompareValue($contents);
+    }
+
+    return $value->isBooleanMode()
+      ? min($compare, 1)
+      : ceil(($compare / count(explode(' ', $contents))) * 10);
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    if (!($value = $this->getValue())
+        || !($contents = $this->_getContents($model)))
+      return false;
+
+    foreach ($value->getTerms() as $term) {
+      if ($value->isStrict() && !$term->containVolatile($contents))
+        return false;
+
+      if (!$value->isStrict() && $term->containVolatile($contents))
+        return true;
+    }
+
+    return $value->isStrict();
+  }
+
+
+  protected function _getOperator() : string {
+    return $this->_operator . '(' . $this->_key . ')';
+  }
+
+
+  protected function _clauseFormatDb() : string {
+    return $this->_getOperator();
+  }
+
+
+  protected function _getContents(array $model) : string {
+    $contents = '';
+    foreach (explode(',', $this->_key) as $key)
+      if (array_key_exists($key, $model))
+        $contents = implode(' ', array_filter([$contents, $model[$key]]));
+
+    return $contents;
+  }
+}
diff --git a/src/Storm/Query/Clause/MatchTerms.php b/src/Storm/Query/Clause/MatchTerms.php
new file mode 100644
index 0000000000000000000000000000000000000000..d255ac6bd1e29699615d45d0f73610c780eaff58
--- /dev/null
+++ b/src/Storm/Query/Clause/MatchTerms.php
@@ -0,0 +1,91 @@
+<?php
+/*
+STORM is under the MIT License (MIT)
+
+Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+gof this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+
+
+class Storm_Query_Clause_MatchTerms {
+
+  protected array $_values;
+  protected bool $_exact;
+
+  public function __construct(array $values, bool $exact) {
+    $this->_values = $values;
+    $this->_exact = $exact;
+  }
+
+
+  public function getFormatDb(bool $strict) : string {
+    if (!($count = count($this->_values)))
+      return '';
+
+    $format_db = $strict ? '+' : '';
+    $values = $this->_getValues();
+
+    if ($this->_exact)
+      return $format_db . '"' . $values . '"';
+
+    $format_db .= (!$strict || 1 === $count)
+      ? '%s'
+      : '(%s)';
+
+    return sprintf($format_db, $values);
+  }
+
+
+  public function containVolatile(string $contents) : bool {
+    if ($this->_exact)
+      return $this->_containValue($contents, $this->_getValues());
+
+    foreach ($this->_values as $value)
+      if ($this->_containValue($contents, $value))
+        return true;
+
+    return false;
+  }
+
+
+  public function getCompareValue(string $contents) : int {
+    if ($this->_exact)
+      return preg_match_all('/\b' . $this->_getValues() . '\b/', $contents);
+
+    $compare = 0;
+    foreach ($this->_values as $value)
+      $compare += ($new_compare = preg_match_all('/\b' . $value . '\b/', $contents))
+        ? $new_compare
+        : 0;
+
+    return $compare;
+  }
+
+
+  protected function _containValue(string $contents, string $value) : bool {
+    return preg_match('/\b' . $value . '\b/', $contents);
+  }
+
+
+  protected function _getValues() : string {
+    return implode(' ', $this->_values);
+  }
+}
diff --git a/src/Storm/Query/Clause/OrderBy.php b/src/Storm/Query/Clause/OrderBy.php
new file mode 100644
index 0000000000000000000000000000000000000000..72deff1ba0c1f24b4f10d04cdde4aa47592c29d2
--- /dev/null
+++ b/src/Storm/Query/Clause/OrderBy.php
@@ -0,0 +1,84 @@
+<?php
+/*
+STORM is under the MIT License (MIT)
+
+Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+
+
+class Storm_Query_Clause_OrderBy extends Storm_Query_Clause {
+
+  const ORDER_DESC = ' desc';
+
+  protected ?string $_order_mode = null;
+
+  public function assemble($select) : self {
+    $select->order((($clause = $this->getValue())
+                    ? $clause->getFormatDb($select->getTable())
+                    : $this->_key)
+                   . $this->_order_mode);
+    return $this;
+  }
+
+
+  public function getFormatDb() : string {
+    return '';
+  }
+
+
+  public function containAttibuteInVolatile(array $model) : bool {
+    return true;
+  }
+
+
+  public function setOrder(bool $mode) : self {
+    $this->_order_mode = $mode ? static::ORDER_DESC : '';
+    return $this;
+  }
+
+
+  public function compare(Storm_Query_Order $query_order, array $a, array $b) : int {
+    $compare = $this->_compareValues($a, $b)
+      * $this->_direction()
+      * $query_order->getPosition();
+
+    return ($next_order = $query_order->getNextQuery())
+      ? ($compare + $next_order->getClause()->compare($next_order, $a, $b))
+      : $compare;
+  }
+
+
+  protected function _compareValues(array $a, array $b) : int {
+    if ($match_clause = $this->getValue())
+      return $match_clause->compare($a, $b);
+
+    $first = $this->_existKeyInModel($a) ? $a[$this->_key] : '';
+    $second = $this->_existKeyInModel($b) ? $b[$this->_key] : '';
+
+    return ($first <=> $second);
+  }
+
+
+  protected function _direction() : int {
+    return static::ORDER_DESC === $this->_order_mode ? -1 : 1;
+  }
+}
diff --git a/src/Storm/Query/Criteria.php b/src/Storm/Query/Criteria.php
new file mode 100644
index 0000000000000000000000000000000000000000..48ba096139fb96df0ec4e623fe38dd899358b588
--- /dev/null
+++ b/src/Storm/Query/Criteria.php
@@ -0,0 +1,233 @@
+<?php
+/*
+  STORM is under the MIT License (MIT)
+
+  Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
+
+*/
+
+
+class Storm_Query_Criteria implements Storm_Query_CriteriaInterface {
+
+  const
+    SEPARATOR_AND = 'and',
+    SEPARATOR_OR = 'or';
+
+  protected array $_clauses;
+  protected string $_separator;
+
+
+  public function __construct() {
+    $this->_clauses = [];
+    $this->_separator = static::SEPARATOR_AND;
+  }
+
+
+  public function beOr() : self {
+    $this->_separator = static::SEPARATOR_OR;
+    return $this;
+  }
+
+
+  public function separator() : string {
+    return ' ' . $this->_separator . ' ';
+  }
+
+
+  public function assemble(Storm_Query_Sql $sql) : self {
+    foreach ($this->_clauses as $clause)
+      $sql->write($this->_assembleOne($clause));
+
+    return $this;
+  }
+
+
+  /**
+   * @param $clause Storm_Query_CriteriaInterface|Storm_Query_Clause
+   */
+  protected function _assembleOne(object $clause) : string {
+    if ($clause instanceof Storm_Query_CriteriaInterface) {
+      $sql = new Storm_Query_Sql;
+      $clause->assemble($sql);
+
+      return '(' . $sql->implode($clause->separator()) . ')';
+    }
+
+    return $clause->getFormatDb();
+  }
+
+
+  public function containsAllAttributes(array $model) : bool {
+    return static::SEPARATOR_AND === $this->_separator
+      ? $this->_containsAllAttributesForAnd($model)
+      : $this->_containsAllAttributesForOr($model);
+  }
+
+
+  protected function _containsAllAttributesForAnd(array $model) : bool {
+    foreach ($this->_clauses as $clause)
+      if (!$this->_containsOne($clause, $model))
+        return false;
+
+    return true;
+  }
+
+
+  protected function _containsAllAttributesForOr(array $model) : bool {
+    foreach ($this->_clauses as $clause)
+      if ($this->_containsOne($clause, $model))
+        return true;
+
+    return false;
+  }
+
+
+  protected function _containsOne($clause, array $model) : bool {
+    if ($clause instanceof Storm_Query_CriteriaInterface)
+      return $clause->containsAllAttributes($model);
+
+    return $clause->containAttibuteInVolatile($model);
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function eq(string $key, $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::equal($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function not_eq(string $key, $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::equal($key, $value)
+      ->setNegated(true);
+    return $this;
+  }
+
+
+  public function like(string $key, string $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::like($key, $value);
+    return $this;
+  }
+
+
+  public function not_like(string $key, string $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::like($key, $value)
+      ->setNegated(true);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function gt(string $key, $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::greater($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function gt_eq(string $key, $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::greaterEqual($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function lt(string $key, $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::lesser($key, $value);
+    return $this;
+  }
+
+
+  /**
+   * @param $value int | string
+   */
+  public function lt_eq(string $key, $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::lesserEqual($key, $value);
+    return $this;
+  }
+
+
+  public function is_null(string $key) : self {
+    $this->_clauses [] = Storm_Query_Clause::isNull($key);
+    return $this;
+  }
+
+
+  public function not_is_null(string $key) : self {
+    $this->_clauses [] = Storm_Query_Clause::isNull($key)
+      ->setNegated(true);
+    return $this;
+  }
+
+
+  public function in(string $key, array $array) : self {
+    $this->_clauses [] = Storm_Query_Clause::in($key, $array);
+    return $this;
+  }
+
+
+  public function not_in(string $key, array $array) : self {
+    $this->_clauses [] = Storm_Query_Clause::in($key, $array)
+      ->setNegated(true);
+    return $this;
+  }
+
+
+  public function start(string $key, string $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::start($key, $value);
+    return $this;
+  }
+
+
+  public function end(string $key, string $value) : self {
+    $this->_clauses [] = Storm_Query_Clause::end($key, $value);
+    return $this;
+  }
+
+
+  public function or(Storm_Query_CriteriaInterface $criteria) : self {
+    $this->_clauses [] = $criteria->beOr();
+    return $this;
+  }
+
+
+  public function and(Storm_Query_CriteriaInterface $criteria) : self {
+    $this->_clauses [] = $criteria;
+    return $this;
+  }
+
+
+  public function match(Storm_Query_MatchBoolean $match) : self {
+    $this->_clauses [] = Storm_Query_Clause::match($match);
+    return $this;
+  }
+}
diff --git a/src/Storm/Query/CriteriaInterface.php b/src/Storm/Query/CriteriaInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..4f44e2981efcf35723e6f09d2fd516144faf2d3c
--- /dev/null
+++ b/src/Storm/Query/CriteriaInterface.php
@@ -0,0 +1,100 @@
+<?php
+/*
+  STORM is under the MIT License (MIT)
+
+  Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
+
+*/
+
+
+interface Storm_Query_CriteriaInterface {
+
+  public function beOr() : self;
+
+
+  /**
+   * @param $value int | string
+   */
+  public function eq(string $key, $value) : self;
+
+
+  /**
+   * @param $value int | string
+   */
+  public function not_eq(string $key, $value) : self;
+
+
+  public function like(string $key, string $value) : self;
+
+
+  public function not_like(string $key, string $value) : self;
+
+
+  /**
+   * @param $value int | string
+   */
+  public function gt(string $key, $value) : self;
+
+
+  /**
+   * @param $value int | string
+   */
+  public function gt_eq(string $key, $value) : self;
+
+
+  /**
+   * @param $value int | string
+   */
+  public function lt(string $key, $value) : self;
+
+
+  /**
+   * @param $value int | string
+   */
+  public function lt_eq(string $key, $value) : self;
+
+
+  public function is_null(string $key) : self;
+
+
+  public function not_is_null(string $key) : self;
+
+
+  public function in(string $key, array $array) : self;
+
+
+  public function not_in(string $key, array $array) : self;
+
+
+  public function start(string $key, string $value) : self;
+
+
+  public function end(string $key, string $value) : self;
+
+
+  public function or(Storm_Query_CriteriaInterface $criteria) : self;
+
+
+  public function and(Storm_Query_CriteriaInterface $criteria) : self;
+
+
+  public function match(Storm_Query_MatchBoolean $match) : self;
+}
diff --git a/src/Storm/Query/MatchBoolean.php b/src/Storm/Query/MatchBoolean.php
new file mode 100644
index 0000000000000000000000000000000000000000..15ab7d81df4b4133c4a6dd681eba14140f65b759
--- /dev/null
+++ b/src/Storm/Query/MatchBoolean.php
@@ -0,0 +1,39 @@
+<?php
+/*
+  STORM is under the MIT License (MIT)
+
+  Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
+
+*/
+
+
+class Storm_Query_MatchBoolean extends Storm_Query_MatchRating {
+
+  public function __construct($array_or_key, bool $strict = false) {
+    parent::__construct($array_or_key, $strict);
+    $this->_boolean_mode = true;
+  }
+
+
+  public function exact($array_or_value) : self {
+    return $this->_addTerm($array_or_value, true);
+  }
+}
diff --git a/src/Storm/Query/MatchRating.php b/src/Storm/Query/MatchRating.php
new file mode 100644
index 0000000000000000000000000000000000000000..8dfcb720f4fd7a485736a089073fa3e9e5a02de9
--- /dev/null
+++ b/src/Storm/Query/MatchRating.php
@@ -0,0 +1,78 @@
+<?php
+/*
+  STORM is under the MIT License (MIT)
+
+  Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
+
+*/
+
+
+class Storm_Query_MatchRating {
+
+  protected string $_key;
+  protected array $_terms;
+  protected bool $_boolean_mode;
+  protected bool $_strict;
+
+  public function __construct($array_or_key, bool $strict = false) {
+    $this->_key = is_array($array_or_key)
+      ? implode(', ', $array_or_key)
+      : $array_or_key;
+    $this->_terms = [];
+    $this->_boolean_mode = false;
+    $this->_strict = $strict;
+  }
+
+
+  public function isBooleanMode() : bool {
+    return $this->_boolean_mode;
+  }
+
+
+  public function isStrict() : bool {
+    return $this->_strict;
+  }
+
+
+  public function getKey() : string {
+    return $this->_key;
+  }
+
+
+  public function getTerms() : array {
+    return $this->_terms;
+  }
+
+
+  public function against($array_or_value) : self {
+    return $this->_addTerm($array_or_value);
+  }
+
+
+  protected function _addTerm($array_or_value, bool $exact = false) : self {
+    if (!is_array($array_or_value))
+      $array_or_value = explode(' ', $array_or_value);
+
+    $this->_terms [] = (new Storm_Query_Clause_MatchTerms($array_or_value,
+                                                          $exact));
+    return $this;
+  }
+}
diff --git a/src/Storm/Query/Sql.php b/src/Storm/Query/Sql.php
new file mode 100644
index 0000000000000000000000000000000000000000..53b347978859740a6b0c070175a3c3e208c80a99
--- /dev/null
+++ b/src/Storm/Query/Sql.php
@@ -0,0 +1,41 @@
+<?php
+/*
+STORM is under the MIT License (MIT)
+
+Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+
+
+class Storm_Query_Sql {
+
+  protected array $_sql = [];
+
+  public function write(string $sql) : self {
+    $this->_sql [] = $sql;
+    return $this;
+  }
+
+
+  public function implode(string $glue) : string {
+    return implode($glue, $this->_sql);
+  }
+}
diff --git a/src/Storm/Test/ModelTestCase.php b/src/Storm/Test/ModelTestCase.php
index 70fd693750af9719e6369b8bbbfb1e43a6b69071..bf7534a240423b308cbaa6df428239418652c421 100644
--- a/src/Storm/Test/ModelTestCase.php
+++ b/src/Storm/Test/ModelTestCase.php
@@ -27,13 +27,43 @@ THE SOFTWARE.
 abstract class Storm_Test_ModelTestCase extends PHPUnit_Framework_TestCase {
   use Storm_Test_THelpers;
 
+  protected $_old_adapter;
+  protected bool $_storm_mock_zend_adapter = true;
+
   protected function setUp() {
     Storm_Model_Abstract::unsetLoaders();
+
+    $this->_old_adapter = Zend_Db_Table_Abstract::getDefaultAdapter();
+
+    if (!$this->_storm_mock_zend_adapter)
+      return;
+
+    $adapter = new class() extends Zend_Db_Adapter_Mysqli
+    {
+      public function __construct($config=[]) { }
+
+
+      public function getConfig() {
+        return ['dbname' => 'mockdb'];
+      }
+
+
+      protected function _connect() {
+        $this->_connection = Storm_Test_ObjectWrapper::mock()
+          ->whenCalled('real_escape_string')
+          ->willDo(fn($value) => $value);
+
+        return true;
+      }
+    };
+
+    Zend_Db_Table_Abstract::setDefaultAdapter($adapter);
   }
 
 
   protected function tearDown() {
     Storm_Model_Abstract::unsetLoaders();
+    Zend_Db_Table_Abstract::setDefaultAdapter($this->_old_adapter);
   }
 
 
@@ -43,5 +73,3 @@ abstract class Storm_Test_ModelTestCase extends PHPUnit_Framework_TestCase {
                             $message);
   }
 }
-
-?>
\ No newline at end of file
diff --git a/tests/Storm/Test/LoaderQueryTest.php b/tests/Storm/Test/LoaderQueryTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..297c8bdb6384a9ec63385ba8514de9f5e1724896
--- /dev/null
+++ b/tests/Storm/Test/LoaderQueryTest.php
@@ -0,0 +1,886 @@
+<?php
+/*
+  STORM is under the MIT License (MIT)
+
+  Copyright (c) 2010-2022 Agence Française Informatique http://www.afi-sa.fr
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
+*/
+
+
+abstract class Storm_Test_LoaderQueryTestCase extends Storm_Test_ModelTestCase {
+
+  protected
+    $_select,
+    $_table,
+    $_loader;
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_table = $this->mock();
+    $this->_loader = Storm_Test_Mock_User::getLoader()->setTable($this->_table);
+  }
+}
+
+
+
+
+class Storm_Test_LoaderQueryDbTest extends Storm_Test_LoaderQueryTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_table
+      ->whenCalled('select')->answers($this->_select = $this->mock())
+      ->whenCalled('fetchAll')->with($this->_select)
+      ->answers(new Zend_Db_Table_Rowset([]));
+
+    $this->_select->whenCalled('where')->answers($this->_select)
+                  ->whenCalled('getTable')->answers($this->_table);
+  }
+
+
+  /** @test */
+  public function withClauseGreaterThanShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->gt('id', '30')
+      ->fetchAll();
+
+    $this->assertEquals(['id>\'30\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseGreaterThanEqualShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->gt_eq('id', '30')
+      ->fetchAll();
+
+    $this->assertEquals(['id>=\'30\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseLesserThanShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->lt('id', '30')
+      ->fetchAll();
+
+    $this->assertEquals(['id<\'30\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseLesserThanEqualShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->lt_eq('id', '30')
+      ->fetchAll();
+
+    $this->assertEquals(['id<=\'30\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseIsShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->is_null('name')
+      ->fetchAll();
+
+    $this->assertEquals(['name is null'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseIsNotShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->not_is_null('name')
+      ->fetchAll();
+
+    $this->assertEquals(['name is not null'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseInShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->in('name', ['Harlock', 'Nausicaa'])
+      ->fetchAll();
+
+    $this->assertEquals(['name in (\'Harlock\', \'Nausicaa\')'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseNotInShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->not_in('name', ['Harlock', 'Nausicaa'])
+      ->fetchAll();
+
+    $this->assertEquals(['name not in (\'Harlock\', \'Nausicaa\')'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseEqualShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->eq('left(name, 5)', 'ganda')
+      ->fetchAll();
+
+    $this->assertEquals(['left(name, 5)=\'ganda\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseNotEqualShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->not_eq('name', 'Harlock')
+      ->fetchAll();
+
+    $this->assertEquals(['name!=\'Harlock\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseLikeShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->like('login', '%aus%')
+      ->fetchAll();
+
+    $this->assertEquals(['login like \'%aus%\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseNotLikeShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->not_like('login', '%aus%')
+      ->fetchAll();
+
+    $this->assertEquals(['login not like \'%aus%\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseStartShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->start('login', 'aus')
+      ->fetchAll();
+
+    $this->assertEquals(['login like \'aus%\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseEndShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->end('login', 'aus')
+      ->fetchAll();
+
+    $this->assertEquals(['login like \'%aus\''],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseOrShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->or((new Storm_Query_Criteria)
+           ->eq('login', 'aus')
+           ->in('role', ['admin', 'invite']))
+      ->fetchAll();
+
+    $this->assertEquals(['(login=\'aus\' or role in (\'admin\', \'invite\'))'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseAndShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->and((new Storm_Query_Criteria)
+            ->eq('login', 'aus')
+            ->in('role', ['admin', 'invite']))
+      ->fetchAll();
+
+    $this->assertEquals(['(login=\'aus\' and role in (\'admin\', \'invite\'))'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseMatchShouldReturnQueryInBooleanMode() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->match((new Storm_Query_MatchBoolean('login, role'))
+              ->against(['ADMIN', 'INVITE']))
+      ->fetchAll();
+
+    $this->assertEquals(['MATCH(login, role) AGAINST(\'ADMIN INVITE\' IN BOOLEAN MODE)'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseMatchMultipleTermsShouldReturnQueryInBooleanMode() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->match((new Storm_Query_MatchBoolean('login,role'))
+              ->against('ADMIN INVITE')
+              ->against('HUGO ADRIEN'))
+      ->fetchAll();
+
+    $this->assertEquals(['MATCH(login,role) AGAINST(\'ADMIN INVITE HUGO ADRIEN\' IN BOOLEAN MODE)'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+
+
+  /** @test */
+  public function withClauseMatchStrictAndOneExactExpressionWithNoBooleanModeShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->match((new Storm_Query_MatchBoolean(['login', 'role'], true))
+              ->exact('ADMIN INVITE')
+              ->against('HUGO'))
+      ->fetchAll();
+
+    $this->assertEquals(['MATCH(login, role) AGAINST(\'+"ADMIN INVITE" +HUGO\' IN BOOLEAN MODE)'],
+                        $this->_select->getAttributesForLastCallOn('where'));
+  }
+}
+
+
+
+
+class Storm_Test_LoaderQueryLimitTest extends Storm_Test_LoaderQueryTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_table
+      ->whenCalled('select')->answers($this->_select = $this->mock())
+      ->whenCalled('fetchAll')->with($this->_select)
+      ->answers(new Zend_Db_Table_Rowset([]));
+
+    $this->_select->whenCalled('limit')->answers($this->_select);
+  }
+
+
+  /** @test */
+  public function withClauseLimit30ShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->limit(30)
+      ->fetchAll();
+
+    $this->assertEquals([30],
+                        $this->_select->getAttributesForLastCallOn('limit'));
+  }
+
+
+  /** @test */
+  public function withClauseLimit30Offset10ShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->limit('10, 30')
+      ->fetchAll();
+
+    $this->assertEquals(['10, 30'],
+                        $this->_select->getAttributesForLastCallOn('limit'));
+  }
+}
+
+
+
+
+class Storm_Test_LoaderQueryLimitPageTest extends Storm_Test_LoaderQueryTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_table
+      ->whenCalled('select')->answers($this->_select = $this->mock())
+      ->whenCalled('fetchAll')->with($this->_select)
+      ->answers(new Zend_Db_Table_Rowset([]));
+
+    $this->_select->whenCalled('limitPage')->answers($this->_select);
+  }
+
+
+  /** @test */
+  public function withClauseLimitPageShouldReturnQuery() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->limit_page([1, 100])
+      ->fetchAll();
+
+    $this->assertEquals([1, 100],
+                        $this->_select->getAttributesForLastCallOn('limitPage'));
+  }
+}
+
+
+
+
+class Storm_Test_LoaderQueryOrderTest extends Storm_Test_LoaderQueryTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_table
+      ->whenCalled('select')->answers($this->_select = $this->mock())
+      ->whenCalled('fetchAll')->with($this->_select)
+      ->answers(new Zend_Db_Table_Rowset([]));
+
+    $this->_select->whenCalled('order')->answers($this->_select)
+                  ->whenCalled('getTable')->answers($this->_table);
+  }
+
+
+  /** @test */
+  public function withClauseOrderIdShouldReturnOrderId() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->order('id')
+      ->fetchAll();
+
+    $this->assertEquals(['id'],
+                        $this->_select->getAttributesForLastCallOn('order'));
+  }
+
+
+  /** @test */
+  public function withClauseOrderIdDescShouldReturnOrderIdDesc() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->order_desc('id')
+      ->fetchAll();
+
+    $this->assertEquals(['id desc'],
+                        $this->_select->getAttributesForLastCallOn('order'));
+  }
+
+
+  /** @test */
+  public function withMultipleClauseOrderShouldReturnAllOrders() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->order_desc('id')
+      ->order('name')
+      ->order_desc('role')
+      ->fetchAll();
+
+    $this->assertEquals(['id desc', 'name', 'role desc'],
+                        [$this->_select->getFirstAttributeForMethodCallAt('order', 0),
+                         $this->_select->getFirstAttributeForMethodCallAt('order', 1),
+                         $this->_select->getFirstAttributeForMethodCallAt('order', 2)]);
+  }
+
+
+  /** @test */
+  public function withMatchClauseOrderShouldReturnMatchOrder() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->order((new Storm_Query_MatchRating('login, role'))
+              ->against(['ADMIN', 'INVITE']))
+      ->fetchAll();
+
+    $this->assertEquals(['MATCH(login, role) AGAINST(\'ADMIN INVITE\')'],
+                        $this->_select->getAttributesForLastCallOn('order'));
+  }
+
+
+  /** @test */
+  public function withMatchClauseOrderDescShouldReturnMatchOrder() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->order_desc((new Storm_Query_MatchBoolean('login, role'))
+                   ->against(['ADMIN', 'INVITE']))
+      ->fetchAll();
+
+    $this->assertEquals(['MATCH(login, role) AGAINST(\'ADMIN INVITE\' IN BOOLEAN MODE) desc'],
+                        $this->_select->getAttributesForLastCallOn('order'));
+  }
+}
+
+
+
+
+class Storm_Test_LoaderQueryGroupByTest extends Storm_Test_LoaderQueryTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_table
+      ->whenCalled('select')->answers($this->_select = $this->mock())
+      ->whenCalled('fetchAll')->with($this->_select)
+      ->answers(new Zend_Db_Table_Rowset([]));
+
+    $this->_select->whenCalled('group')->answers($this->_select);
+  }
+
+
+  /** @test */
+  public function withGroupByLoaderShouldCallGroupOnDbSelect() {
+    Storm_Query::from(Storm_Test_Mock_User::class)
+      ->group('name')
+      ->fetchAll();
+
+    $this->assertEquals(['name'],
+                        $this->_select->getAttributesForLastCallOn('group'));
+  }
+}
+
+
+
+
+class Storm_Test_LoaderQueryUser extends Storm_Model_Abstract {
+
+  protected $_table_name = 'users';
+}
+
+
+
+
+class Storm_Test_LoaderQueryVolatileClauseWhereTest extends Storm_Test_ModelTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture(Storm_Test_LoaderQueryUser::class,
+                   ['id' => 100,
+                    'login' => 'user_admin',
+                    'level' => 'invite admin redacteur',
+                    'foo' => 'premier deuxieme troisieme',
+                    'prefs' => null]);
+
+    $this->fixture(Storm_Test_LoaderQueryUser::class,
+                   ['id' => 101,
+                    'login' => 'user_administrateur',
+                    'level' => 'administrateur',
+                    'foo' => 'deuxieme redacteur',
+                    'prefs' => '{"best":true}']);
+
+    $this->fixture(Storm_Test_LoaderQueryUser::class,
+                   ['id' => 102,
+                    'login' => 'user_invite',
+                    'level' => 'invite',
+                    'foo' => 'premier deuxieme']);
+  }
+
+
+  /** @test */
+  public function whereId_GT_100_ShouldAnswersUserAdministrateurAndUserInvite() {
+    $this->assertEquals(['user_administrateur', 'user_invite'],
+                        (new Storm_Model_Collection(Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                                                    ->gt('id', 100)
+                                                    ->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function whereId_GT_EQ_101_ShouldAnswersUserAdministrateurAndUserInvite() {
+    $this->assertEquals(['user_administrateur', 'user_invite'],
+                        (new Storm_Model_Collection(Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                                                    ->gt_eq('id', 101)
+                                                    ->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function whereId_LT_102_ShouldAnswersUserAdminAndUserAdministrateur() {
+    $this->assertEquals(['user_admin', 'user_administrateur'],
+                        (new Storm_Model_Collection(Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                                                    ->lt('id', 102)
+                                                    ->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function whereId_LT_EQ_101_ShouldAnswersUserAdminAndUserAdministrateur() {
+    $this->assertEquals(['user_admin', 'user_administrateur'],
+                        (new Storm_Model_Collection(Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                                                    ->lt_eq('id', 101)
+                                                    ->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function whereLogin_NOT_EQ_UserAdministrateurShouldReturnUserAdminAndUserInvite() {
+    $this->assertEquals(['user_admin', 'user_invite'],
+                        (new Storm_Model_Collection(Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                                                    ->not_eq('login', 'user_administrateur')
+                                                    ->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function whereId_IN_100And102_AndFoo_NOT_LIKE_TroisieShouldReturnId_102() {
+    $this->assertEquals([102],
+                        (new Storm_Model_Collection(Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                                                    ->in('id', [100, 102])
+                                                    ->not_like('foo', '%troisie%')
+                                                    ->fetchAll()))
+                        ->collect('id')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function whereId_IN_100And101_AndFoo_START_DeuxShouldReturnId_101() {
+    $this->assertEquals([101],
+                        (new Storm_Model_Collection(Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                                                    ->in('id', [100, 101])
+                                                    ->start('foo', 'deux')
+                                                    ->fetchAll()))
+                        ->collect('id')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function whereFoo_END_ActeurShouldReturnId_101() {
+    $this->assertEquals([101],
+                        (new Storm_Model_Collection(Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                                                    ->end('foo', 'acteur')
+                                                    ->fetchAll()))
+                        ->collect('id')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatchLevelAgainstAdminShouldAnswersOnlyUser_Admin() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->match((new Storm_Query_MatchBoolean('level'))
+              ->against(['admin']));
+    $this->assertEquals(['user_admin'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatchLevelAgainstAdminDeuxiemeShouldAnswersUser_AdminAndUser_AdministrateurAndUser_Invite() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->match((new Storm_Query_MatchBoolean('level,foo'))
+              ->against(['admin', 'deuxieme']));
+    $this->assertEquals(['user_admin', 'user_administrateur', 'user_invite'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatchLevelAgainstStrictAdminRedacteur_DeuxiemeShouldAnswersUser_AdminAndUser_Administrateur() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->match((new Storm_Query_MatchBoolean('level,foo', true))
+              ->against(['admin', 'redacteur'])
+              ->against(['deuxieme']));
+    $this->assertEquals(['user_admin', 'user_administrateur'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatchLevelAgainstExactDeuxiemeTroisiemeShouldAnswersOnlyUser_Admin() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->match((new Storm_Query_MatchBoolean('level,foo'))
+              ->exact('deuxieme redacteur'));
+    $this->assertEquals(['user_administrateur'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withOrClause_LevelInviteOrFooLikePremierShouldAnswersUser_AdminAndUser_Invite() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->or((new Storm_Query_Criteria)
+           ->eq('level', 'invite')
+           ->like('foo', '%premier%'));
+    $this->assertEquals(['user_admin', 'user_invite'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withAndClause_LevelInviteOrFooLikePremierShouldAnswersOnlyUser_Invite() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->and((new Storm_Query_Criteria)
+            ->eq('level', 'invite')
+            ->like('foo', '%premier%'));
+    $this->assertEquals(['user_invite'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+
+  /** @test */
+  public function withIsNullPrefsShouldAnswersUser_100_Only() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->and((new Storm_Query_Criteria)->is_null('prefs'));
+
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(100)],
+                        $query->fetchAll());
+  }
+
+
+  /** @test */
+  public function withIsNotNullPrefsShouldAnswersUsers_101_and_102() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->and((new Storm_Query_Criteria)->not_is_null('prefs'));
+
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(101),
+                         Storm_Test_LoaderQueryUser::find(102)],
+                        $query->fetchAll());
+  }
+
+
+  /** @test */
+  public function withNotInInviteAdministrateurShouldAnswerUser_100_Only() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->not_in('level', ['invite', 'administrateur']);
+
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(100)],
+                        $query->fetchAll());
+  }
+
+
+  /** @test */
+  public function withClauseLimitOneShouldReturnOnlyUser_100() {
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(100)],
+                        Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                        ->limit(1)
+                        ->fetchAll());
+  }
+
+
+  /** @test */
+  public function withClauseLimitOneTwoShouldReturnTwoUser_101_102() {
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(101),
+                         Storm_Test_LoaderQueryUser::find(102)],
+                        Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                        ->limit('1, 2')
+                        ->fetchAll());
+  }
+
+
+  /** @test */
+  public function withClauseLimitPageShouldPaginate() {
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(100),
+                         Storm_Test_LoaderQueryUser::find(101)],
+                        Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                        ->limit_page([0, 2])
+                        ->fetchAll());
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(100),
+                         Storm_Test_LoaderQueryUser::find(101)],
+                        Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                        ->limit_page([1, 2])
+                        ->fetchAll());
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(102)],
+                        Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                        ->limit_page([2, 2])
+                        ->fetchAll());
+  }
+
+
+  /** @test */
+  public function withClauseLimitPageAndAttributeShouldWork() {
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(100),
+                         Storm_Test_LoaderQueryUser::find(102)],
+                        Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+                        ->start('foo', 'premier')
+                        ->limit_page([1, 2])
+                        ->fetchAll());
+  }
+}
+
+
+
+
+class Storm_Test_LoaderQueryVolatileClauseOrderTest
+  extends Storm_Test_ModelTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture(Storm_Test_LoaderQueryUser::class,
+                   ['id' => 100,
+                    'login' => 'user_admin',
+                    'level' => 'invite admin redacteur',
+                    'foo' => 'first second third',
+                    'order1' => 'abcd',
+                    'order2' => 2,
+                    'order3' => 'defg']);
+
+    $this->fixture(Storm_Test_LoaderQueryUser::class,
+                   ['id' => 101,
+                    'login' => 'user_administrateur',
+                    'level' => 'administrateur',
+                    'foo' => 'third second forth',
+                    'order1' => 'abcdef',
+                    'order2' => 3,
+                    'order3' => 'defg']);
+
+    $this->fixture(Storm_Test_LoaderQueryUser::class,
+                   ['id' => 102,
+                    'login' => 'user_invite',
+                    'level' => 'invite',
+                    'foo' => 'forth fifth seven',
+                    'order1' => 'cdef',
+                    'order2' => 1,
+                    'order3' => 'klmn']);
+  }
+
+
+  /** @test */
+  public function with_Order1Desc_ShouldAnswersInOrdered() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->order_desc('order1');
+    $this->assertEquals(['user_invite', 'user_administrateur', 'user_admin'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function with_Order3Asc_Order2Desc_ShouldAnswersOrderedUser() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->order('order3')
+      ->order_desc('order2');
+    $this->assertEquals(['user_administrateur', 'user_admin', 'user_invite'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function with_Order3Desc_Order2Desc_ShouldAnswersOrderedUser() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->order_desc('order3')
+      ->order_desc('order2');
+    $this->assertEquals(['user_invite', 'user_administrateur', 'user_admin'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatch_Level_Administrateur_ShouldAnswersOrderedUser() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->order((new Storm_Query_MatchRating('level'))
+              ->against('administrateur'));
+    $this->assertEquals(['user_admin', 'user_invite', 'user_administrateur'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatch_Level_Administrateur_Redacteur_Desc_ShouldAnswersOrderedUser() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->order_desc((new Storm_Query_MatchRating('level'))
+                   ->against('administrateur redacteur'));
+    $this->assertEquals(['user_administrateur', 'user_admin', 'user_invite'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatchInBooleanMode_Level_Administrateur_Redacteur_Desc_ShouldAnswersOrderedUser() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->order_desc((new Storm_Query_MatchBoolean('level'))
+                   ->against('administrateur redacteur'));
+    $this->assertEquals(['user_admin', 'user_administrateur', 'user_invite'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatch_FooDescExacteInBooleanMode_Forth_Fifth_Level_Administrateur_Redacteur_ShouldAnswersOrderedUser() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->order_desc((new Storm_Query_MatchBoolean('foo'))
+                   ->exact('forth fifth'))
+      ->order((new Storm_Query_MatchRating('level'))
+              ->against('administrateur redacteur'));
+    $this->assertEquals(['user_invite', 'user_admin', 'user_administrateur'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+
+
+  /** @test */
+  public function withMatch_FooLevelStrict_InviteRedacteur_SecondThird_ShouldAnswersOrderedUser() {
+    // https://www.php.net/manual/fr/function.usort
+    // If two members compare as equal, they retain their original order. Prior to PHP 8.0.0, their relative order in the sorted array was undefined.
+    if (version_compare(PHP_VERSION, '8.0.0', '<'))
+      return;
+
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->order((new Storm_Query_MatchBoolean('foo,level', true))
+              ->against('invite redacteur')
+              ->against('second third'));
+    $this->assertEquals([Storm_Test_LoaderQueryUser::find(101),
+                         Storm_Test_LoaderQueryUser::find(102),
+                         Storm_Test_LoaderQueryUser::find(100)],
+                        $query->fetchAll());
+  }
+
+
+  /** @test */
+  public function selectUsersGroupByOrder3ShouldAnswersUserAdminAndUserInvite() {
+    $query = Storm_Query::from(Storm_Test_LoaderQueryUser::class)
+      ->group('order3');
+    $this->assertEquals([0 => 'user_admin', 2 => 'user_invite'],
+                        (new Storm_Model_Collection($query->fetchAll()))
+                        ->collect('login')
+                        ->getArrayCopy());
+  }
+}
diff --git a/tests/Storm/Test/LoaderTest.php b/tests/Storm/Test/LoaderTest.php
index e7ab7352007a52b1be079896e130fe46807df2ad..50eec16ee9072b6432b7526be00df3e1d968765c 100644
--- a/tests/Storm/Test/LoaderTest.php
+++ b/tests/Storm/Test/LoaderTest.php
@@ -1,26 +1,26 @@
 <?php
 /*
-STORM is under the MIT License (MIT)
-
-Copyright (c) 2010-2011 Agence Française Informatique http://www.afi-sa.fr
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+  STORM is under the MIT License (MIT)
+
+  Copyright (c) 2010-2011 Agence Française Informatique http://www.afi-sa.fr
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+  THE SOFTWARE.
 */
 
 
@@ -38,15 +38,15 @@ abstract class Storm_Test_LoaderTestCase extends Storm_Test_ModelTestCase {
 
       ->whenCalled('quoteInto')
       ->willDo(function($clause, $value)
-               {
-                 return str_replace('?',
-                                    is_array($value)
-                                    ? implode(',',
-                                              array_map(function($item){ return "'" . $item . "'"; },
-                                                        $value))
-                                    :  "'" . $value . "'",
-                                    $clause);
-               });
+      {
+        return str_replace('?',
+                           is_array($value)
+                           ? implode(',',
+                                     array_map(function($item){ return "'" . $item . "'"; },
+                                               $value))
+                           :  "'" . $value . "'",
+                           $clause);
+      });
 
     $this->_loader = Storm_Test_Mock_User::getLoader()->setTable($this->_table);
   }
@@ -158,41 +158,17 @@ class Storm_Test_LoaderBasicTest extends Storm_Test_LoaderTestCase {
     return
       [
        [ ['name' => null] , ['name is null'] ],
-       [ ['name' => ['Harlock', 'Nausicaa']], ['name in (\'Harlock\',\'Nausicaa\')'] ],
+       [ ['name' => ['Harlock', 'Nausicaa']], ['name in (\'Harlock\', \'Nausicaa\')'] ],
        [ ['name not' => 'Harlock'], ['name!=\'Harlock\''] ],
        [ ['name not' => ['Harlock', 'Nausicaa']],
-        ['name not in (\'Harlock\',\'Nausicaa\')'] ],
+        ['name not in (\'Harlock\', \'Nausicaa\')'] ],
        [ ['name not' => null], ['name is not null'] ],
        [ ['login like' => '%aus%'], ['login like \'%aus%\''] ],
        [ ['login not like' => '%aus%'], ['login not like \'%aus%\''] ],
        [ ['where' => 'id=\'29\' and name=\'lp\''], ['id=\'29\' and name=\'lp\''] ],
        [ ['left(name, 5)' => 'ganda'], ['left(name, 5)=\'ganda\''] ],
        [ ['id' => '29'], ['id=\'29\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseGreater('id', '30') ],
-        ['id>\'30\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseGreaterEqual('id', '30') ],
-        ['id>=\'30\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseLesser('id', '30') ],
-        ['id<\'30\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseLesserEqual('id', '30') ],
-        ['id<=\'30\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseIs('name') ], ['name is null'] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseIn('name', ['Harlock', 'Nausicaa']) ],
-        ['name in (\'Harlock\',\'Nausicaa\')'] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseNotEqual('name', 'Harlock') ],
-        ['name!=\'Harlock\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseNotIn('name', ['Harlock', 'Nausicaa']) ],
-        ['name not in (\'Harlock\',\'Nausicaa\')'] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseLike('login', '%aus%') ],
-        ['login like \'%aus%\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseNotLike('login', '%aus%') ],
-        ['login not like \'%aus%\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseEqual('left(name, 5)', 'ganda') ],
-        ['left(name, 5)=\'ganda\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseStart('login', 'aus') ],
-        ['login like \'aus%\''] ],
-       [ [ Storm_Model_PersistenceStrategy_Clause::clauseEnd('login', 'aus') ],
-        ['login like \'%aus\''] ],
+       [ [ Storm_Query_Clause::greater('id', '30') ], ['id>\'30\''] ],
       ];
   }
 
@@ -243,7 +219,7 @@ class Storm_Test_LoaderBasicTest extends Storm_Test_LoaderTestCase {
     $this->_table->whenCalled('delete')->answers(2);
     $this->_loader->basicDeleteBy(['nom' => ['bond', 'l\'upin']]);
 
-    $this->assertEquals("nom in ('bond','l'upin')",
+    $this->assertEquals("nom in ('bond', 'l'upin')",
                         $this->_table->getFirstAttributeForLastCallOn('delete'));
   }
 
@@ -286,7 +262,7 @@ class Storm_Test_LoaderBasicTest extends Storm_Test_LoaderTestCase {
                               ['type' => 'fictive']);
 
     $this->assertEquals([['type' => 'fictive'],
-                         "nom in ('bond','lupin') and type='real'"],
+                         "nom in ('bond', 'lupin') and type='real'"],
                         $this->_table->getAttributesForLastCallOn('update'));
   }
 
@@ -317,7 +293,7 @@ class Storm_Test_LoaderSaveWithIdTest extends Storm_Test_LoaderTestCase {
       ->answers($this->_select = $this->mock());
 
     $this->_select
-      ->whenCalled('where')->with('id=\'4\'')->answers($this->_select)
+      ->whenCalled('where')->with('id=4')->answers($this->_select)
       ->whenCalled('from')->with($this->_table, ['count(*) as numberof'])->answers($this->_select);
 
   }
@@ -353,7 +329,6 @@ class Storm_Test_LoaderSaveWithIdTest extends Storm_Test_LoaderTestCase {
       ->whenCalled('update')
       ->answers(1);
 
-
     Storm_Test_Mock_User::newInstanceWithId(4,
                                             [])->save($force_primary_key = true);
     $this->assertArraySubset(['id' => 4],
diff --git a/tests/Storm/Test/LoaderVolatileTest.php b/tests/Storm/Test/LoaderVolatileTest.php
index 3e91fdf9f94fdd2e3b9bbaab7b14f7854ecb00be..b7f466050fd1c0f1a4e5a94cdfd6de616755870f 100644
--- a/tests/Storm/Test/LoaderVolatileTest.php
+++ b/tests/Storm/Test/LoaderVolatileTest.php
@@ -148,7 +148,6 @@ class Storm_Test_LoaderVolatileTest extends Storm_Test_ModelTestCase {
                                                        'level' => 'invite',
                                                        'brain_id' => null,
                                                        'id_mouth' => null]);
-
   }
 
 
@@ -779,78 +778,6 @@ class Storm_Test_LoaderVolatileTest extends Storm_Test_ModelTestCase {
   }
 
 
-  /** @test */
-  public function selectAllCatsWhereIdMoreThanThreeShouldAnswersFifiAndLoulou() {
-    $this->assertEquals(['fifi', 'loulou'],
-                        (new Storm_Model_Collection(Storm_Test_VolatileCat::findAllBy([Storm_Test_VolatileCat::clauseGreater('id', 3)])))
-                        ->collect('name')
-                        ->getArrayCopy());
-  }
-
-
-  /** @test */
-  public function selectAllCatsWhereIdMoreOrEqualThanForShouldAnswersFifiAndLoulou() {
-    $this->assertEquals(['fifi', 'loulou'],
-                        (new Storm_Model_Collection(Storm_Test_VolatileCat::findAllBy([Storm_Test_VolatileCat::clauseGreaterEqual('id', 4)])))
-                        ->collect('name')
-                        ->getArrayCopy());
-  }
-
-
-  /** @test */
-  public function selectAllCatsWhereIdLesserThanFiveShouldAnswersRiriAndFifi() {
-    $this->assertEquals(['riri', 'fifi'],
-                        (new Storm_Model_Collection(Storm_Test_VolatileCat::findAllBy([Storm_Test_VolatileCat::clauseLesser('id', 5)])))
-                        ->collect('name')
-                        ->getArrayCopy());
-  }
-
-
-  /** @test */
-  public function selectAllCatsWhereIdLesserOrEqualThanForShouldAnswersRiriAndFifi() {
-    $this->assertEquals(['riri', 'fifi'],
-                        (new Storm_Model_Collection(Storm_Test_VolatileCat::findAllBy([Storm_Test_VolatileCat::clauseLesserEqual('id', 4)])))
-                        ->collect('name')
-                        ->getArrayCopy());
-  }
-
-
-  /** @test */
-  public function selectUserLoginNotHubertShouldReturnAlbertAndZoe() {
-    $this->assertEquals(['albert', 'zoe'],
-                        (new Storm_Model_Collection(Storm_Test_VolatileUser::findAllBy([Storm_Test_VolatileUser::clauseNotEqual('login', 'hubert')])))
-                        ->collect('login')
-                        ->getArrayCopy());
-  }
-
-
-  /** @test */
-  public function selectUserLoginInAlbertZoeAndFooNotLikeSnafuShouldReturnAlbert() {
-    $this->assertEquals(['albert'],
-                        (new Storm_Model_Collection(Storm_Test_VolatileUser::findAllBy([Storm_Test_VolatileUser::clauseIn('login', ['albert', 'zoe']), Storm_Test_VolatileUser::clauseNotLike('foo', '%naf%')])))
-                        ->collect('login')
-                        ->getArrayCopy());
-  }
-
-
-  /** @test */
-  public function selectUserLoginInAlbertZoeAndFooStartSnaShouldReturnZoe() {
-    $this->assertEquals(['zoe'],
-                        (new Storm_Model_Collection(Storm_Test_VolatileUser::findAllBy([Storm_Test_VolatileUser::clauseIn('login', ['albert', 'zoe']), Storm_Test_VolatileUser::clauseStart('foo', 'sna')])))
-                        ->collect('login')
-                        ->getArrayCopy());
-  }
-
-
-  /** @test */
-  public function selectUserLoginInAlbertZoeAndFooEndAfuShouldReturnZoe() {
-    $this->assertEquals(['zoe'],
-                        (new Storm_Model_Collection(Storm_Test_VolatileUser::findAllBy([Storm_Test_VolatileUser::clauseIn('login', ['albert', 'zoe']), Storm_Test_VolatileUser::clauseEnd('foo', 'afu')])))
-                        ->collect('login')
-                        ->getArrayCopy());
-  }
-
-
   /** @test */
   public function selectUsersGroupByFooShouldAnswersAlbertAndHubert() {
     $this->assertEquals(['albert', 'hubert'],