What we call queriers are at the heart of QueryQL. They map roughly 1-to-1 to
models / tables / resources, but they don't have to. For example, say you have a
user resource – you could have a UserQuerier
for your public API at /users
,
and a more permissive AdminUserQuerier
for your private API at /admin/users
.
It's up to you.
The code behind queriers that maps calls to a query builder / ORM is called an adapter. At this point, the only officially supported adapter is for Knex – which is the default adapter out of the box – but anyone can build their own adapter.
To define a querier, simply extend QueryQL
with your own class:
const QueryQL = require('@truepic/queryql')
class UserQuerier extends QueryQL {
defineSchema(schema) {
// ...
}
}
The only required function is defineSchema(schema)
, which whitelists what's
allowed. However...
We highly recommend creating a BaseQuerier
that all of your queriers extend,
instead of extending QueryQL
each time. This allows you to set defaults and
encapsulate any other functionality you need in one place. For example, to set
the default page size to 10 for all queriers:
class BaseQuerier extends QueryQL {
get pageDefaults() {
return {
size: 10,
}
}
}
class UserQuerier extends BaseQuerier {
defineSchema(schema) {
// ...
}
}
To run a querier, start by creating a new instance of one:
const querier = new UserQuerier(query, builder, (config = {}))
query
is a parsed query string, like Express'req.query
. (Make sure your framework uses a query string parser that supports nested objects. Node.js's nativequerystring
module does not, but a package like qs does. It's usually a simple config change to switch.)builder
is a query builder / ORM object that the configured adapter works with, like Knex.config
is an optional instance-specific config that overrides the global config (see Config).
Then call run()
:
let users
try {
users = await querier.run()
} catch (error) {
// Handle validation error...
}
If successful, the builder
passed in when constructing the querier is returned
with the appropriate filtering, sorting, and pagination applied.
If validation fails, however, a ValidationError
is thrown. See
Validation for more.
QueryQL uses the following config defaults:
{
adapter: KnexAdapter,
validator: JoiValidator,
}
These defaults, however, can easily be changed for all querier instances. For example, to use a different adapter:
const { Config } = require('@truepic/queryql')
const MyAdapter = require('./my_adapter')
Config.defaults = {
adapter: MyAdapter,
}
You only need to specify the keys you want to override – the defaults will be used otherwise.
A config can also be passed as the third argument when creating an instance of a querier:
const MyAdapter = require('./my_adapter')
const querier = new UserQuerier(query, builder, {
adapter: MyAdapter,
})
Filtering is specified under the filter
key in the query string. A number of
formats are supported:
?filter[name]=value
?filter[name][operator]=value
operator
can be optional if the adapter specifies a default operator. If not,operator
is required.=
is the default operator for the Knex adapter.value
can be a string, number, boolean, array, object, ornull
. If an object, however, theoperator
must be specified to avoid ambiguity. Each adapter can add additional validation to restrict thevalue
of a specificoperator
. For example, anin
operator might require an arrayvalue
.
As you can see, both operator
and value
are very much adapter-specific. This
flexibility allows for adapters that work with SQL, NoSQL, APIs, etc., rather
than forcing them all into an SQL-like box.
That said, the default Knex adapter supports the following operators/values:
=
: string, number, or boolean!=
: string, number, or boolean<>
: string, number, or boolean>
: string or number>=
: string or number<
: string or number<=
: string or numberis
:null
/'null'
as a string / empty string (all mean the same)is not
:null
/'null'
as a string / empty string (all mean the same)in
: array of strings and/or numbersnot in
: array of strings and/or numberslike
: stringnot like
: stringilike
: stringnot ilike
: stringbetween
: array of two strings and/or numbersnot between
: array of two strings and/or numbers
In the querier's defineSchema(schema)
function, a filter can be
added/whitelisted by calling:
schema.filter(name, operatorOrOperators, (options = {}))
For example:
class UserQuerier extends BaseQuerier {
// ...
defineSchema(schema) {
// ...
schema.filter('id', 'in', { field: 'users.id' })
schema.filter('status', ['=', '!='])
}
}
Name | Description | Type | Default |
---|---|---|---|
field |
The underlying field (i.e., database column) to use if different than the filter name. | String | Filter name |
Most of the time, you can rely on the adapter to automatically apply the appropriate function calls to your query builder / ORM. In some cases, however, you may need to bypass the adapter and work with the query builder / ORM directly.
This can easily be done by defining a function in your querier class to handle
the name[operator]
combination. For example:
class UserQuerier extends BaseQuerier {
// ...
'filter:id[in]'(builder, { name, field, operator, value }) {
return builder.where(field, operator, value)
}
}
As you can see, you simply call the appropriate function(s) on the query builder
/ ORM and return the builder
.
Now, this example is overly simplistic, and probably already handled appropriately by the adapter. It becomes more useful, for example, when you have a filter that doesn't map directly to a field in your database, like a search query:
class UserQuerier extends BaseQuerier {
// ...
'filter:q[=]'(builder, { value }) {
return builder
.where('first_name', 'like', `%${value}%`)
.orWhere('last_name', 'like', `%${value}%`)
}
}
When the filter
key isn't set in the query, you can set a default filter by
defining a get defaultFilter()
function in your querier. For example:
class UserQuerier extends BaseQuerier {
// ...
get defaultFilter() {
return {
status: 2,
}
}
}
Any of the supported formats can be returned.
Not to be confused with the previous section – which allows you to set a default
filter when none is specified – the defaults that are applied to every filter
can be changed by defining a get filterDefaults()
function in your querier.
For example, here are the existing defaults:
class UserQuerier extends BaseQuerier {
// ...
get filterDefaults() {
return {
name: null,
field: null,
operator: null,
value: null,
}
}
}
You only need to return the keys you want to override.
Sorting is specified under the sort
key in the query string. A number of
formats are supported:
?sort=name
?sort[]=name
?sort[name]=order
order
can beasc
ordesc
(case-insensitive), and defaults toasc
.sort[]
andsort[name]
support multiple sorts, just be aware that the two formats can't be mixed.
In the querier's defineSchema(schema)
function, a sort can be
added/whitelisted by calling:
schema.sort(name, (options = {}))
For example:
class UserQuerier extends BaseQuerier {
// ...
defineSchema(schema) {
// ...
schema.sort('name')
schema.sort('status', { field: 'current_status' })
}
}
Name | Description | Type | Default |
---|---|---|---|
field |
The underlying field (i.e., database column) to use if different than the sort name. | String | Sort name |
Most of the time, you can rely on the adapter to automatically apply the appropriate function calls to your query builder / ORM. In some cases, however, you may need to bypass the adapter and work with the query builder / ORM directly.
This can easily be done by defining a function in your querier class to handle
the name
. For example:
class UserQuerier extends BaseQuerier {
// ...
'sort:name'(builder, { name, field, order }) {
return builder.orderBy(field, order)
}
}
As you can see, you simply call the appropriate function(s) on the query builder
/ ORM and return the builder
.
Now, this example is overly simplistic, and probably already handled appropriately by the adapter. It becomes more useful, for example, when you have a sort that doesn't map directly to a field in your database:
class UserQuerier extends BaseQuerier {
// ...
'sort:name'(builder, { order }) {
return builder.orderBy('last_name', order).orderBy('first_name', order)
}
}
When the sort
key isn't set in the query, you can set a default sort by
defining a get defaultSort()
function in your querier. For example:
class UserQuerier extends BaseQuerier {
// ...
get defaultSort() {
return 'name'
}
}
Any of the supported formats can be returned.
Not to be confused with the previous section – which allows you to set a default
sort when none is specified – the defaults that are applied to every sort can
be changed by defining a get sortDefaults()
function in your querier. For
example, here are the existing defaults:
class UserQuerier extends BaseQuerier {
// ...
get sortDefaults() {
return {
name: null,
field: null,
order: 'asc',
}
}
}
You only need to return the keys you want to override.
Pagination is specified under the page
key in the query string. A number of
formats are supported:
?page=number
?page[number]=value&page[size]=value
number
can be any positive integer, and defaults to1
.size
can be any positive integer, and defaults to20
.
In the querier's defineSchema(schema)
function, pagination can be enabled by
calling:
schema.page((isEnabledOrOptions = true))
For example:
class UserQuerier extends BaseQuerier {
// ...
defineSchema(schema) {
// ...
schema.page()
}
}
When the page
key isn't set in the query, you can set a default page by
defining a get defaultPage()
function in your querier. For example:
class UserQuerier extends BaseQuerier {
// ...
get defaultPage() {
return 2
}
}
Any of the supported formats can be returned.
Not to be confused with the previous section – which allows you to set a default
page when none is specified – the defaults that are applied to every page can
be changed by defining a get pageDefaults()
function in your querier. For
example, here are the existing defaults:
class UserQuerier extends BaseQuerier {
// ...
get pageDefaults() {
return {
size: 20,
number: 1,
}
}
}
You only need to return the keys you want to override.
QueryQL and the configured adapter validate the query structure and value types
for free, without any additional configuration. You don't have to worry about
the client misspelling a name or using an unsupported filter operator – a
ValidationError
will be thrown if they do.
Still, it's often helpful to add your own app-specific validation. For example,
ensuring that a status
filter is only the string open
or closed
, or that
page size
isn't greater than 100
. It's also recommended to prevent invalid
values from reaching your database and causing query errors.
QueryQL provides validation out of the box with Joi, although anyone can build their own validator to use a validation library they're more familiar with.
Simply define a defineValidation(schema)
function in your querier that returns
the validation schema:
class UserQuerier extends BaseQuerier {
// ...
defineValidation(schema) {
return {
'filter:status[=]': schema.string().valid('open', 'closed'),
'page:size': schema.number().max(100),
}
}
}
Validation is triggered automatically when run()
is called on the querier, but
can also be called manually with validate()
. A ValidationError
is thrown
if/when it fails.
At this point, a ValidationError
is simply an extended Error
. It doesn't
include any additional fields or functions.
ValidationError
is exported to make it easy to check for an instance of one:
const { ValidationError } = require('@truepic/queryql').errors
const querier = new UserQuerier(query, builder)
let users
try {
users = await querier.run()
} catch (error) {
if (error instanceof ValidationError) {
// Handle validation error.
} else {
// Other error, likely from the query builder / ORM.
}
}