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.
Be the first to Comment!