The Ultimate ORM Query API. Only With Propel.
This post is pretty long, but it explains a new shift in the Propel Query API, and shows how this new syntax will be the killer feature of Propel 1.5. You'll soon learn more about it, but first, let's see is how the idea came.
Better Is Not Best
// Propel 1.4 way
$c = new Criteria();
$c->add(BookPeer::PRICE, 40, Criteria::LESS_THAN);
$c->addAscendingOrderByColumn(BookPeer::TITLE);
$book = BookPeer::doSelectOne($c);
// Propel 1.5 way
$book = PropelQuery::from('Book')
->where('Book.Price < ?', 40)
->orderBy('Book.Title')
->findOne();
But after introducing this new syntax in a recent blog post, I received negative reactions from various Propel users who complained about the lost ability to use autocompletion in an IDE. At first, I saw these complaints as reluctance to change. I thought that people would get used to the new syntax pretty quickly. They would also see how the added benefits of `ModelCriteria` compensate the lost IDE autocompletion. IDE Autocompletion Is A Key FeatureAnd then I tried it myself, with an IDE. I have to admit that autocompletion helps a lot. In fact, using NetBeans, the Propel 1.5 syntax requires a little longer to write than the Propel 1.4 syntax. Besides, the IDE provides the comfort and insurance that the code was correct before running it, while any typo in the 1.5 code can only appear at runtime. That didn't decide me to use an IDE for day-to-day coding - I'm satisfied with the reactivity of Textmate and I don't like the blinking interfaces of IDEs. But that opened my eyes on a key feature that Propel has, and that no other ORM offers. The ability to use autocompletion is one big advantage of Propel that can't be left aside, or else Propel will lose part of its IDE-users community. That didn't decide me to drop the Propel 1.5 syntax either, because after years of using Propel, I can't read a Criteria query without whispering, and I can't write one without complaining. The fast syntax offered by other ORMs makes Propel look like C. The Propel 1.4 syntax is still counterintuitive, and the fact that it relies on class constants makes a lot of thinks impossible. That means that the ModelCriteria syntax is just a step in the path of The Definitive Query Syntax. But then how to improve it?Code Generation To The RescueModelCriteria uses runtime introspection to offer very powerful features. And it also uses code generation to support query behaviors. In fact, any call to `PropelQuery::from('Book')` returns an instance of `BookQuery`, which is a class generated by Propel alongside `Book` and `BookPeer`. But the `PropelQuery::from()` factory has no way to tell an IDE which class it is going to return. The phpDoc for this method stipulates that the return value is an instance of `ModelCriteria`, but in reality it's a subclass of it. So the first step is to improve the generated `BookQuery` to give it the charge of the factory. By generating a `BookQuery::create()` method, returning a new instance of `BookQuery`, it becomes possible to tell the IDE which class is actually in use:
// Old Propel 1.5 way
$book = PropelQuery::from('Book')
->where('Book.Price < ?', 40)
->orderBy('Book.Title')
->findOne();
// New Propel 1.5 way
$book = BookQuery::create()
->where('Book.Price < ?', 40)
->orderBy('Book.Title')
->findOne();
Generated Filter MethodsBut the generated query objects, like `BookQuery`, have very few methods. And there is no way an IDE will help the developer to write the content of a string, as in the `where()` argument. So it's necessary to modify the generator of the custom Query objects again, in order to add one `where()` method for every column of the table. And since the operator cannot be part of the method name, it has to move as second argument of the call. The example query then becomes:
class BookQuery extends BaseBookQuery
{
// this method is generated
/**
* Filter the query on the price column
*
* @param mixed $price The value to use as filter.
* @param string $operator Operator to use for the comparison
*
* @return BookQuery The current query, for fluid interface
*/
public function wherePrice($price, $operator = Criteria::EQUAL)
{
return $this->whereColumn('Price', $price, $operator);
}
}
// New Propel 1.5 way
$book = BookQuery::create()
->wherePrice(40, '<=')
->orderBy('Book.Price')
->findOne();
Notice how the phpDoc of the generated `wherePrice()` method stipulates that the method returns a `BookQuery` and not only a `ModelCriteria`. This is important to allow IDEs to keep on suggesting the correct methods when the fluid interface is used. After seing the `wherePrice()` method call, you probably agree that it is not very good looking ; the operator without the column name is a call for a return to the Criteria constants. Maybe there is an alternative solution. Since the `wherePrice()` method is generated, the generator can use the fact that the `price` column is numeric to offer additional abilities. After further thinking, each column type should offer special comparison types : a string column should support wildcards, a boolean column should support string booleans (like 'no' or 'false'), etc. So after a new addition to the query generator, here is how the query looks:
class BookQuery extends BaseBookQuery
{
// this method is generated
/**
* Filter the query on the price column
*
* @param double|array $price The value to use as filter.
* Accepts an associative array('min' => $minValue, 'max' => $maxValue)
*
* @return BookQuery The current query, for fluid interface
*/
public function wherePrice($price = null)
{
if (is_array($price)) {
if (isset($price['min'])) {
$this->whereColumn('Price', $price['min'], Criteria::GREATER_EQUAL);
}
if (isset($price['max'])) {
$this->whereColumn('Price', $price['max'], Criteria::LESS_EQUAL);
}
} else {
return $this->whereColumn('Price', $price, Criteria::EQUAL);
}
}
}
// New Propel 1.5 way
$book = BookQuery::create()
->wherePrice(array('max' => 40))
->orderBy('Book.Title')
->findOne();
The final touch is to rename these `whereXXX()` methods into `filterByXXX()` (and to rename `whereColumn()` to `filterBy()`). It's not just a matter of taste, it's a similarity with `orderBy()` and `groupBy()`, and a deliberate step away from relational SQL. More on that matter later, but in the meantime, here is the query again:
class BookQuery extends BaseBookQuery
{
// this method is generated
/**
* Filter the query on the price column
*
* @param double|array $price The value to use as filter.
* Accepts an associative array('min' => $minValue, 'max' => $maxValue)
*
* @return BookQuery The current query, for fluid interface
*/
public function filterByPrice($price = null)
{
if (is_array($price)) {
if (isset($price['min'])) {
$this->filterBy('Price', $price['min'], Criteria::GREATER_EQUAL);
}
if (isset($price['max'])) {
$this->filterBy('Price', $price['max'], Criteria::LESS_EQUAL);
}
} else {
return $this->filterBy('Price', $price, Criteria::EQUAL);
}
}
}
// New Propel 1.5 way
$book = BookQuery::create()
->filterByPrice(array('max' => 40))
->orderBy('Book.Price')
->findOne();
phpDoc For Magic MethodsAs for allowing the `orderBy()` method to use IDE completion, using generated methods is out of the question. It is acceptable to add filter methods that react differently according to the column type, it is not acceptable to add one-line proxy methods for each column just to support an `orderByTitle()` syntax. The ModelCriteria already supports a call to `orderByTitle()`, through the magic `__call()` method. But magic and IDE completion don't get along well with each other. Or do they?The phpDocumentor grammar allows one to document magic methods by way of `@method` comments in the class description, that the IDEs recognize. So the alternative to generating one method for every column is to generate one comment line for every column. That way the IDE sees the `orderByTitle()` method at development time, but this method doesn't bloat the `BookQuery` code since `__call()` already handles it pretty well. So the query can be even more 'IDEified' as follows:
/**
* Base class that represents a query for the 'book' table.
*
* Book Table
*
* @method BookQuery orderById($order = Criteria::ASC) Order by the id column
* @method BookQuery orderByTitle($order = Criteria::ASC) Order by the title column
* @method BookQuery orderByISBN($order = Criteria::ASC) Order by the isbn column
* @method BookQuery orderByPrice($order = Criteria::ASC) Order by the price column
* @method BookQuery orderByPublisherId($order = Criteria::ASC) Order by the publisher_id column
* @method BookQuery orderByAuthorId($order = Criteria::ASC) Order by the author_id column
*/
class BookQuery extends BaseBookQuery
{
...
}
// New Propel 1.5 way
$book = BookQuery::create()
->filterByPrice(array('min' => 40))
->orderByTitle()
->findOne();
The same addition is required for `groupByXXX()` methods. And while adding smart phpDoc comments to the query class description, why not override the phpDoc for `findOne()`? That way an IDE knows that the returned `$book` is not just a Propel `BaseObject`, it's a `Book`. How About Relationships?So far, so good, but how about the handling of conditions on related tables? For instance, how to translate this Propel 1.4 query into the new syntax?// Find first book published at 'Penguin' editions // Propel 1.4 way $c = new Criteria(); $c->addJoin(BookPeer::PUBLISHER_ID, PublisherPeer::ID); $c->add(PublisherPeer::NAME, 'Penguin'); $book = BookPeer::doSelectOne($c);This is in fact quite easy, since a recent addition to the `ModelCriteria` class allows it to use a secondary query object instead of applying conditions on a related column. So without any change in the Query generator, this can be written as follows:
// New Propel 1.5 way
$book = BookQuery::create()
->join('Book.Publisher')
->useQuery('Publisher') // returns a new PublisherQuery instance
->filterByName('Penguin')
->endUse() // returns the original BookQuery instance, merged with the PublisherQuery
->findOne();
But the two calls to `join()` and `useQuery()` are not very IDE friendly. No problem, let's modify the query class generator again, so as to add support for a faster `use()` syntax:
class BookQuery extends BaseBookQuery
{
// this method is generated
/**
* Use the Publisher relation Publisher object
*
* @return PublisherQuery A secondary query class using the current class as primary query
*/
public function usePublisherQuery()
{
return $this
->join($this->getModelAliasOrName() . '.Publisher')
->useQuery('Publisher', 'PublisherQuery');
}
}
// New Propel 1.5 way
$book = BookQuery::create()
->usePublisherQuery() // returns a new PublisherQuery instance and makes the join
->filterByName('Penguin')
->endUse() // returns the original BookQuery instance, merged with the PublisherQuery
->findOne();
This is getting very interesting. Since this method is created at generation time, it's possible to create `useXXXQuery()` for every foreign key, but also for the foreign keys of other tables pointing to `Book`. Syntactic SugarAnd the subject of conditions on related tables is not closed. Propel still requires that you know foreign key columns to apply a condition on a related object. For instance:
// Propel 1.4 way // $author is an Author object $c = new Criteria(); $c->add(BookPeer::AUTHOR_ID, $author->getId()); $book = BookPeer::doSelectOne($c); // new Propel 1.5 way $book = BookQuery::create() ->filterByAuthorId($author->getId()) ->findOne();An ORM is all about thinking "objects" rather than "relational", and yet this kind of query forces the developer to remember the relation between the columns of two tables. So in the process of adding generated methods to the Query classes, let's make a tiny but very useful addition:
class BookQuery extends BaseBookQuery
{
// this method is generated
/**
* Filter the query by a related Author object
*
* @param Author $author the related object to use as filter
* @return BookQuery The current query, for fluid interface
*/
public function filterByAuthor(Author $author)
{
return $this
->filterBy('AuthorId', $author->getId());
}
}
$book = BookQuery::create()
->filterByAuthor($author)
->findOne();
Wrapping It All TogetherThe generated query classes now have much more methods and phpDocumentation, and in fact everything is ready to provide a full IDE completion support. Let's check on a final example:
// $author is an Author object
// Find all books by $author, sold for less than 40$, ordered by title, and published at 'Penguin' editions
// Version A: Propel 1.4 way
$c = new Criteria();
$c->add(BookPeer::AUTHOR_ID, $author->getId());
$c->add(BookPeer::PRICE, 40, Criteria::LESS_THAN);
$c->addAscendingOrderByColumn(BookPeer::TITLE);
$c->addJoin(BookPeer::PUBLISHER_ID, PublisherPeer::ID);
$c->add(PublisherPeer::NAME, 'Penguin');
$book = BookPeer::doSelectOne($c);
// Version B: Old Propel 1.5 way
$book = PropelQuery::from('Book')
->where('Book.AuthorId = ?', $author->getId())
->where('Book.Price < ?', 40)
->orderBy('Book.Title')
->join('Book.Publisher')
->where('Publisher.Name = ?', 'Penguin')
->findOne();
// Version C: New Propel 1.5 way
$book = BookQuery::create()
->filterByAuthor($author)
->filterByPrice(array('min' => 40))
->orderByTitle()
->usePublisherQuery()
->filterByName('Penguin')
->endUse()
->findOne();
Version C is both more concise than versions A and B, and totally compatible with IDE completion. It is still very readable, less error prone than version B, and extremely easy to extend. The Meaning Of EverythingIncidentally, the new syntax cuts the link with the SQL query. The syntax doesn't show any `where()` or `join()` calls. This is because the Propel Query API is now truly in the object oriented world. Also, this new Propel Query syntax uses code generation the same way the Propel Models do. Code generation provides intuitive and IDE friendly methods. And these generated methods are very fast, because they don't rely on string parsing or model introspection. Version C is faster to execute than version B. Speed and code completion are the marks of Propel. No other ORM offer such good development tools that are also very performant at runtime. That's the sign that this new syntax is the ultimate query syntax for Propel. One last thing: if version C will be the default syntax for queries in Propel 1.5, versions A and B will also be supported. That means no backwards compatibility problem, and no switching problems for Doctrine users who might want to try the mighty Propel Query API.
40 comments
And this time you made even better than with DbFinder...
Propel is a great project, I'm glad to see it's more than ever in good hands.
Keep up the good work!
Cheers,
Thibault
you made it!
The API ported from DbFinder was already nice but I felt like there are still things missing to match Propels abilities provided by code generation.
This new API looks like the ultimate answer!
One suggestion:
I'm not sure if IDEs support phpDoc return type overwrite by derived classes?
If they do you could also generate phpDoc for the inherited methods of ModelCriteria to return the proper ModelQuery.
Thank you!
Chris
Why the column names are sort of camel-cased in the queries? I mean, it's just a matter of taste or there is a reason? Same for the table name, but I think that is to clarify that we are talking about the "Book" object and not the "book" table, right?
I didn't check the code, and this is totally just out of curiosity :)
As a side note, Propel 1.4 offers two ways to refer to a column: BookPeer::PUBLISHER_ID, and $book->getPublisherId(). Propel 1.5 removes the need for the first syntax, so you are now sure that the right syntax for php code is Camelcase.
Can i say i love You ? :)
This new syntax with this order and filter thing are great, it will Boost speed of code writing with power of Super Cow.
Are these changes available somewhere to test it ?
Just a question: why do you think about removing Peer classes after 1.x? Yes, if all use the new syntax, the underlying code can be refactored for 2.x. But until now this was the place, where I split business logic from its database contents.
If I have ten places where I need the books of the actual year then I create a method for it in the BookPeer which returns the collection.
Writing the query X times may be error-prone. Where we have to place such methods without Peer classes? Or must we create our own classes (what a pity :D)?
regards
i can't make such query
SELECT * FROM table1 WHERE bits & 12 //4+8
because when a row have value 4 or 8 it will be selected and i must make
SELECT * FROM table1 WHERE bits & 12 = 12
With old criteria i must make Custom equal how it will be made here ?
@Nikolai I think You will be able to make Your own methods in BooksQuery Class i think ObjectQuery will replace all functionality of ObjectPeer. Of course if they will be extended in the same way
$ts = Table1Query::create()->where('Table1.bits & ?', 12)->find();
Lately I was looking at Doctrine, since community support was getting a little low for Propel and I didn't had the time/opportunity to improve things myself. However since your work at Propel, and especially this addition, I think many people will consider using Propel (again) for future projects.
At least I do!
Not sure though, if Francoise already faced this problem...
A couple of typos in your code snippets: 3rd code snippet uses both $price and $value (should only be one), and 4th code snippet uses whereByPrice() and wherePrice() (again, should only be one).
Regarding this syntax:
->filterByPrice(array('min' => 40))
When I first looked at that, it seems to be telling me "where the minimum price is 40", or in other words, "WHERE price >= 40", which is exactly the opposite of what it really means.
Is this "min" syntax a standard taken from somewhere else, or has it been created purely for Propel 1.5? It seems kind of counter-intuitive to me.
In fact, I just had a look at the code inside that method, and it looks like it is indeed meant to be used as I described above, and the examples in your post should be using "max" instead of "min", yes? Actually even "max" is not right, since it includes Books that have a price equal to $40, but Versions A and B just query for < $40. I'm sure this will tidied up when Propel 1.5 is released though. ;)
@Mike: The useXXXQuery() methods return the correct phpDoc declaration for return type. Maybe you're referring to the return type declared by endUse()? In that case, there's nothing I can do - except catching that a call to useXXXQuery() on a secondary query built from QueryXXX returns the original XXXQuery, instead of a new one. Does it make sense?
[code lang='php']
$book = BookQuery::create()
->usePublisherQuery() // returns a new PublisherQuery instance and makes the join
->filterByName('Penguin')
->useBookQuery() // returns the original BookQuery instance, merged with the PublisherQuery
->findOne();
[/code]
Maybe a solution is to define magic alternatives for endUse(), like returnToCity()?
[code lang='php']
$book = BookQuery::create()
->usePublisherQuery() // returns a new PublisherQuery instance and makes the join
->useLocationQuery()
->filterByCity('New York')
->usePublisherQuery() // returns PublisherQuery merged w/ LocationQuery
->useBookQuery() // returns BookQuery merged w/ PublisherQuery
->findOne();
[/code]
Also, do you plan to support arbitrary hydration of joined objects in the new syntax?
i m happy that you see autocompletion as a key feature :)
I tried it with sfPropel15Plugin and i can see all the new method however i cannot find any create method on the Query Table Class
Mike i d like that too but i dont see how the join could add the getTableObject method dynamicly, it it even possible in php
The IDE support and the runtime speed are the most important things that I was missing in Doctrine.
Thanks for the good work :-)
The one bit that still bugs me is the use....()....endUse() syntax. Would it be possible to improve the syntax to resemble the following?
// Version D: New New Propel 1.5 way..?
$book = BookQuery::create()
->filterByAuthor($author)
->filterByPrice(array('min' => 40))
->orderByTitle()
->use(PublisherQuery()::create()->filterByName('Penguin'))
->findOne();
(I haven't had the chance to understand yet whether PublisherQuery is the relevant object type - if incorrect replace with a different class invented for this purpose, it's the syntax I'm talking about)
This would make it easier to work with queries pragmatically too.
In the ideal case it would support repeated calls to use() with the same relational Query type e.g:
$book = BookQuery::create()
->use(PublisherQuery()::create()->filterByName('Penguin'))
->use(PublisherQuery()::create()->filterByLocation('New York'))
->findOne();
While the above looks silly since it can obviously be shortened, the thinking is again to support manipulating these queries at run-time.
Best,
-- A
As for repeated use of related queries, which query do you expect with that example? Should there be one or two joins on the publisher table? It's not very obvious.
Also, could you provide me an example of use of your syntax where it would be much better than the current one? I fail to see any use case where it would bring a significant increase in usability...
<?php
$c = new Criteria();
$c->addCond('cond1', BookPeer::TITLE, 'Foo', Criteria::EQUAL); // creates a condition named 'cond1'
$c->addCond('cond2', BookPeer::TITLE, 'Bar', Criteria::EQUAL); // creates a condition named 'cond2'
$c->combine(array('cond1', 'cond2'), Criteria::LOGICAL_OR); // combine 'cond1' and 'cond2' with a logical OR
// translates in SQL as
// WHERE (book.TITLE = 'foo' OR book.TITLE = 'bar')
?>
Where in the php-code "Foo" and "Bar" are written with a Captial, while in the sql-example "foo" and "bar" are used. You do this pretty often, so maybe intentionally but I can't see why...
Besides this I could not yet find anything wrong.
There's two considerations: (A) - use cases / reasons it's better & (B) - how to implement.
(A) Use case: We do a lot of manipulating Criteria using functions. This is great for abstracting generic parts of query construction. E.g.:
function addGenericStuffToCriteria(Criteria $c) {
$c->doStuff..
}
function modifyCriteriaUsingPattern(Criteria $c, $valueA, $valueB) {
$c->doStuff..($valueA);
$c->otherStuffWith..($valueB);
}
and we have Criteria "factories" e.g.
function generateCriteriaForSomething() {
$c = new Criteria();
$c->doStuff..
return $c;
}
$c = generateCriteriaForSomething();
// add last-minute customisations for this specific use
$c->doStuff..
Because of the genius move of making the new PropelQuery stuff extend ModelCriteria which extends the original Criteria, this is still all possible using 100% the cool new syntax..until you try and work with filters on the query that affect related objects - i.e. the use...() stuff. Basically I'm talking about the ability to do anything you want with a query object and obtain predictable results without knowing what's been done with it before, as you can with the current Criteria.
Hopefully you can see now how the patterns above are equivalent to the repeated use of related queries I gave before. To clarify, there should be just the single join produced by that example.
Other reasons why it's better syntax:
- it cuts out a method call - endUse() - and with it the scope for bugs when we forget to include it / make mistakes during repeated editing of complex queries etc.
- it solves the problem of "losing" the type of the original Query object for autocompletion etc, since FooQuery::useBlahQuery() will always return FooQuery just like everything else.
- it's more "object orientated" style, rather than the procedural "BEGIN..END" pattern.. (if that's even a reason! ;) This is the theoretical basis for the additional flexibility it gives, though)
ok, now (B) - how to implement. I realised it wasn't possible to use that exact code with the current implementation as it stands, that's why I suggested you imagine a different class if relevant.
If you agree that it's improved syntax, you could do something like the following to implement:
- have the "parent" query notify the "child" query that it's embedded during the use() method so that it can use a suitable table alias. make sure that it doesn't need to know this right from the beginning - I assume everything's stored as metadata inside the object until it needs to be executed, just like with the current Criteria - so that shouldn't be too much of a problem?
- if the above is a problem, what would be required is a lightweight metadata class rather than the full query object that can hold the subquery data until the "parent" query requires it, during the final execution of the query.
- have the "parent" query store a list of all it's "child" queries, indexed by object type so they can be combined in the relevant way if passed more than once for the same object.
Any more clarifications required, let me know.
Thanks,
-- A
Anyway, I like your syntax better than the 'procedural' one, and I think we should go on looking for a solution. But I suggest that we continue the discussion on the developers mailing-list, so that other devs gave give their opinion on the matter.
For the last examples, C should be like:
...
# ->filterByPrice(array('min' => 39))
...
Because the condition in A is Criteria::LESS_THAN, and filterByXXX uses Criteria::LESS_EQUAL
Thanks a lot Francois. I loved DbFinder but the new autocomplete abilities make Propel 1.5 so much nicer.
This is an excellent demonstration of Documentation Driven Development!
It filled my heart with yonder!
Great job on propel! Just in time to make me decide to stick with it :)
Kind regards,
Ralph
Please I need some help.
I tried to do something like that wit propel.
select distinct b.parameter_id, b.* from table1 a inner join table2 As b on a.parameter_id = 5 and a.pk_table1 = b.fk_table2 inner join table3 as c ON b.some_parameter_id = 1 and b.pk_table2 = c.fk_table3.
And i found the following problems.
first: can't format the select statement
->select(array('b.ParameterId', 'b.*')) // here b.* not works
How can I do this?
second:
when joined tables propel add the conditions the filterBy and where statement are push to the end of a query in where clause; How I can add a condition filter in the join it self to concatenate filter and where.
I hope you understand my english y you will can answer to me.
Thaks
Best Regards.