Consul Caesar.js stands before you with a new assignment: "The censor asks which centurions collected the most valuable tributes this month. He wants the list sorted from the richest down." You open the repository you know well... and stop. The
find() method handles simple questions beautifully: "give me tributes worth over 1000 denarii", "find a legionary by name". But summing, grouping by centurion, conditions assembled only while the program runs - none of that can find() express.For questions like these, TypeORM has a more powerful tool: the Query Builder. Think of it as the treasury's cartographer - you build the query step by step, method by method, and at the end it draws one precise map (the SQL query) out of those steps and sends it to the database.
Let's start with a question you could also ask through
find() - that's the easiest way to see what the Query Builder does differently. We are looking for tributes worth more than 1000 denarii, most valuable first:1const tributes = await this.tributeRepository
2 .createQueryBuilder('tribute')
3 .where('tribute.value > :minValue', { minValue: 1000 })
4 .orderBy('tribute.value', 'DESC')
5 .getMany();Let's walk the chain.
createQueryBuilder('tribute') opens the build and gives the entity an alias - a short nickname we use in every later method, writing tribute.value or tribute.name. where(...) adds a condition, orderBy(...) adds sorting. Only getMany() glues those blocks into SQL and sends it to the database - before that, nothing reaches the database at all. The result is a plain array of Tribute[] entities, exactly what find() would have given you - the way of building the question changed, the shape of the answer did not.In the
where condition we did not write the number 1000 directly. In its place stands :minValue - a parameter, whose value we pass alongside, in the object { minValue: 1000 }. Why the separation? Imagine the value comes from a user. Pasted straight into the query text, it could smuggle in a hostile order - like a messenger hiding a command to open the gates inside a letter. That attack is called SQL injection:1// BAD - user data glued into the query text
2.where(`tribute.name = '${userInput}'`)
3
4// GOOD - data travels separately, as a parameter
5.where('tribute.name = :name', { name: userInput })The difference is fundamental: a parameter is never glued into the SQL text. The database receives it through a separate channel and treats it purely as data - even if someone types a fragment of a malicious query into a form, it stays nothing more than odd-looking text. So adopt an iron rule, @name: every value in a query goes through a parameter. No exceptions.
Real searches rarely have a single condition. You add the next one with
andWhere - it must hold together with the previous ones. There is also orWhere - it is enough that any one of them holds:1const tributes = await this.tributeRepository
2 .createQueryBuilder('tribute')
3 .where('tribute.value >= :min', { min: 1000 })
4 .andWhere('tribute.type = :type', { type: 'gold' })
5 .getMany();This query finds tributes that are golden and worth at least 1000 denarii. And because the query is an ordinary object built with methods, we can also add conditions conditionally - inside an
if:1let query = this.tributeRepository.createQueryBuilder('tribute');
2
3if (criteria.minValue) {
4 query = query.andWhere('tribute.value >= :min', { min: criteria.minValue });
5}
6if (criteria.type) {
7 query = query.andWhere('tribute.type = :type', { type: criteria.type });
8}
9
10const tributes = await query.orderBy('tribute.value', 'DESC').getMany();And this is the moment the Query Builder leaves
find() far behind: the query grows while the program runs, condition by condition, depending on what the user asks for - and it still travels to the database as a single SQL statement, only at getMany().Tributes do not exist in a vacuum - each belongs to a legionary, and every legionary serves under a centurion. To fetch tributes together with their owners' data, we join the relation:
1const tributes = await this.tributeRepository
2 .createQueryBuilder('tribute')
3 .leftJoinAndSelect('tribute.legionariusze', 'legionary')
4 .where('legionary.isActive = :active', { active: true })
5 .getMany();The name
leftJoinAndSelect is two decisions written as one word. LeftJoin: attach the legionary, but do not discard a tribute that has no owner - its field will simply be empty (the twin innerJoinAndSelect would drop such a tribute from the result). AndSelect: also load the legionary's data into the result entities. The second argument, 'legionary', is the alias of the joined entity - it is what let us filter by legionary.isActive in the where. There is also plain leftJoin, without AndSelect: the relation then serves only for filtering and its data is not loaded - the result is lighter. That is what I recommend whenever you do not display the owner's data.Back to the censor's question: the total value of tributes by type. The answer is not a list of tributes but a table of summaries - a few rows with counts and sums. For queries like this we pick the columns ourselves and close the build with
getRawMany() instead of getMany():1const stats = await this.tributeRepository
2 .createQueryBuilder('tribute')
3 .select('tribute.type', 'type')
4 .addSelect('COUNT(*)', 'count')
5 .addSelect('SUM(tribute.value)', 'totalValue')
6 .groupBy('tribute.type')
7 .orderBy('totalValue', 'DESC')
8 .getRawMany();select picks the first column, every addSelect adds another, and the second argument names it in the result. groupBy folds rows into groups - COUNT and SUM are then computed separately for every tribute type. The ending is the crucial part: getMany() returns Tribute entities, but a sum or a count is not an entity - so we call getRawMany(), which returns raw rows with the fields you named yourself: { type, count, totalValue }.The Empire's treasury holds thousands of tributes and nobody views them all at once. We serve results in pages:
1const [tributes, total] = await this.tributeRepository
2 .createQueryBuilder('tribute')
3 .orderBy('tribute.value', 'DESC')
4 .skip((page - 1) * limit)
5 .take(limit)
6 .getManyAndCount();skip skips the results belonging to previous pages, take fetches one portion. getManyAndCount() returns a pair: the portion of results and the total number of all matching rows - from it you compute the page count: Math.ceil(total / limit). Note that orderBy is not decoration here but a correctness requirement: without a fixed order the database could serve rows differently every time, and pages would start to overlap.Look how many answers you can already draw out of the treasury:
createQueryBuilder('alias') opens the build, and the alias is the entity's nickname used inside it,where, andWhere and orWhere - dynamically too, inside an if,:name parameter - your shield against SQL injection,leftJoinAndSelect, or the lighter leftJoin when they only filter,select, addSelect and groupBy, collecting them with getRawMany(),skip and take pair, and getManyAndCount() adds the total count.In the next lesson you will meet transactions - a way to make several treasury operations execute as one: all of them, or none. For now remember: the Query Builder is a cartographer - you lay down the blocks, and it draws them into a single SQL map only when you call
getMany().