Let's Learn Adonis 5: Writing Reusable Queries with Query Scopes

In this lesson, we'll learn how we can extract repetitive query builder statements into reusable query scopes as a way to keep our codebase easy to maintain.

Published
Mar 20, 21
Duration
9m 26s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

As you begin building applications with Adonis, it's likely you'll come across repetitious query statements. For example, What if we need to almost always only include incomplete tasks when we're querying from our Task model? It can be tedious and difficult to maintain to write that where statement for each query. To aid with this, Adonis has a concept called query scopes.

Query scopes allow us to extract a portion of our query builder statement out into a static method on our model. Then, anytime we need to utilize this extracted statement, we can do so in the query builder using the apply method.

Defining A Query Scope

Let's use our task example from above for our first query scope. So, let's say we have a query that looks something like the below, which queries the latest tasks that aren't yet completed.

const incompleteTasks = await Task.query()
  .whereNot('status_id', Status.COMPLETE)  // 👈 let's extract this
  .orderBy('createdAt', 'desc')
Copied!

First, let's head over to our Task model, since this is the model we want the query scope to be available for. Next, let's define the static query scope method. For this example, since the query scope will query all tasks not yet completed, we'll use the name incomplete, but we could name it whatever.

import { BaseModel, scope, /* ... other imports */ } from '@ioc:Adonis/Lucid/Orm'
import Status from 'Contracts/Enums/Status'

export default class Task extends BaseModel {
  // ... other stuff

  public static incomplete = scope((query) => {
    query.whereNot('status_id', Status.COMPLETE)
  })
}
Copied!

We'll need to import scope from @ioc:Adonis/Lucid/Orm. Next, we define our public static method, incomplete. Then, we'll call scope, which accepts a callback function that provides us our query instance. From here, all we need to do is act like we're any ol' query builder.

Using A Query Scope

Now that we have our query scope defined on our Task model, let's put it to use. From our previous example, we'll want to replace our whereNot line, with our incomplete query scope. To do this, we'll make use of the apply method available on our query builder. Apply, will provide us a callback function providing us our defined Task query scopes, which we just need to call as a function.

const incompleteTasks = await Task.query()
  .apply((scopes) => scopes.incomplete())
  .orderBy('createdAt', 'desc')
Copied!

Passing Data To A Query Scope

What if we have a query using dynamic data that we need to use over and over again, say maybe we want all incomplete tasks owned by a specific user? No fret, we can pass data for our query scope to use.

Any data passed to our query scope will be available in the second parameter in our scope's defined callback function. So, if we want to accept a userId, we can do so by defining a second parameter variable of userId.

import { BaseModel, scope, /* ... other imports */ } from '@ioc:Adonis/Lucid/Orm'
import Status from 'Contracts/Enums/Status'

export default class Task extends BaseModel {
  // ... other stuff

  public static incomplete = scope((query, userId: Number) => {
    query
      .whereNot('status_id', Status.COMPLETE)
      .where('user_id', userId)
  })
}
Copied!

Then, to provide this to our query scope, we just need to pass it as an argument to the function.

const userId = 1
const incompleteTasks = await Task.query()
  .apply((scopes) => scopes.incomplete(userId))
  .orderBy('createdAt', 'desc')
Copied!

Want to make the query scope a little bit more dynamic? We can make the userId optional, easy peasy! You can make use of the if method to make your queries more dynamic.

import { BaseModel, scope, /* ... other imports */ } from '@ioc:Adonis/Lucid/Orm'
import Status from 'Contracts/Enums/Status'

export default class Task extends BaseModel {
  // ... other stuff

  public static incomplete = scope((query, userId?: number) => {
    query
      .whereNot('status_id', Status.COMPLETE)
      .if(userId, (query) => query.where('user_id', <number>userId))
  })
}
Copied!

Using the if method, we can make our userId optional, noted by the ?. Then, check to see if a userId value is truthy using the if method. Lastly, we can cast our userId to a number instead of number | undefined since we know it has a value if that section of our code is executed.

Or, you can even perform checks outside the query itself.

import { BaseModel, scope, /* ... other imports */ } from '@ioc:Adonis/Lucid/Orm'
import Status from 'Contracts/Enums/Status'

export default class Task extends BaseModel {
  // ... other stuff

  public static incomplete = scope((query, userId?: number) => {
    if (!userId) return;

    query
      .whereNot('status_id', Status.COMPLETE)
      .where('user_id', userId)
  })
}
Copied!

Stacking Query Scopes

Let's say we have multiple query scopes defined for out Task model and we wanted to use multiple on a single query. Just like everything else, Adonis makes this super easy for us by allowing us to chain scopes directly off one another from within the apply callback. Additionally, we could also call apply itself multiple times as well.

So, let's say we define an additional query scope that will query for all tasks created within the past thirty days.

import { BaseModel, scope, /* ... other imports */ } from '@ioc:Adonis/Lucid/Orm'
import Status from 'Contracts/Enums/Status'
import { DateTime } from 'luxon'

export default class Task extends BaseModel {
  // ... other stuff

  public static incomplete = scope((query, userId: number) => {
    query
      .whereNot('status_id', Status.COMPLETE)
      .where('user_id', userId)
  })

  public static createdThisMonth = scope((query) => {
    const thirtyDaysAgo = DateTime.local().minus({ days: 30 }).toSQL();
    query.where('createdAt', '>=', thirtyDaysAgo);
  })
}
Copied!

We could then apply both of these query scopes in either of the following ways.

const userId = 1
const incompleteTasks = await Task.query()
  .apply((scopes) => scopes.incomplete(userId).createdThisMonth())
  .orderBy('createdAt', 'desc')

// --- OR ---

const userId = 1
const incompleteTasks = await Task.query()
  .apply((scopes) => scopes.incomplete(userId))
  .apply((scopes) => scopes.createdThisMonth())
  .orderBy('createdAt', 'desc')
Copied!

Relationships In Query Scopes

With query scopes, we can harness the full power of the query builder. Because of this, we can also use query scopes to simplify repetitive relationship queries as well. Now, since the scope method has no provided context as to what Model it's in, we need to tell it which Model to use for its typings in order to get TypeScript support for anything beyond the base query builder.

import { BaseModel, scope, /* ... other imports */ } from '@ioc:Adonis/Lucid/Orm'
import { DateTime } from 'luxon'

export default class User extends BaseModel {
  // ... other stuff
  
  public static hasAssignedTasks = scope<typeof User>((query) => {
    query.whereHas('assignedTasks', taskQuery => taskQuery
      .whereNot('status_id', Status.COMPLETE)
    )
  })

  public static withAssignedTasks = scope<typeof User>((query) => {
    query.preload('assignedTasks', taskQuery => taskQuery
      .whereNot('status_id', Status.COMPLETE)
    )
  })
}
Copied!

Nested Query Scopes

Lastly, let's quickly cover nested query scopes, or calling query scopes inside another query scope. Again, here since the scope method has no type context, we need to define the type for it to use. Once we do this, we'll have access and autocomplete support for our nested query scope calls.

import { BaseModel, scope, /* ... other imports */ } from '@ioc:Adonis/Lucid/Orm'
import Status from 'Contracts/Enums/Status'
import { DateTime } from 'luxon'

export default class Task extends BaseModel {
  // ... other stuff

  public static incomplete = scope((query) => {
    query.whereNot('status_id', Status.COMPLETE);
  })

  public static createdThisMonth = scope((query) => {
    query.where('createdAt', '>=', DateTime.local().minus({ days: 30 }).toSQL());
  })

  public static incompleteThisMonth = scope<typeof Task>(query => {
    query.apply(scope => scope.incomplete().createdThisMonth())
  })
}
Copied!

Next Up

With that, we've wrapped up our creating, reading, updating, and deleting (CRUD) lessons! In the next lesson, we'll be learning about validation. Using validation we'll be able to ensure the data we're putting into the database matches exactly what we expect.

Join The Discussion! (0 Comments)

Please sign in or sign up for free to join in on the dicussion.

robot comment bubble

Be the first to Comment!