@trapi/query 🌈
This is a library to build efficient and optimized JSON:API
like REST-APIs.
It extends the specification format between request- & response-handling based on the following query parameters:
fields
Return only specific fields or extend the default selection.filter
: Filter the data set, according specific criteria.include
Include related resources (aka relations
) of the primary data.page
Limit the number of resources returned of the whole set (pagination
).sort
Sort the resources according one or more keys in asc/desc direction.
Important NOTE
The examples in the Parsing section, are not available with current release of the typeorm-extension@0.3.0
library.
Table of Contents
Installation
npm install @trapi/query --save
Usage
Build 🏗
The general idea is to construct a QueryRecord
at the frontend side, which will be formatted to a string and passed to the backend application as URL query string.
The backend application is than always fully capable of processing the request.
Therefore, two components of this module are required in the frontend application:
- generic type:
QueryRecord<T>
- function:
buildQuery
.
The method will generate the query string, which was addressed in the previous section.
In the following example a Class which will represent the structure of a User
and a function called
getAPIUsers
, which will handle the resource request to the resource API, will be defined.
import axios from "axios";
import {QueryRecord, buildQuery} from "@trapi/query";
class Profile {
id: number;
avatar: string;
cover: string;
}
class User {
id: number;
name: string;
age?: number;
profile: Profile;
}
type ResponsePayload = {
data: User[],
meta: {
limit: number,
offset: number,
total: number
}
}
export async function getAPIUsers(
record: QueryRecord<User>
): Promise<ResponsePayload> {
const response = await axios.get('users' + buildQuery(record));
return response.data;
}
(async () => {
const record: QueryRecord<User> = {
page: {
limit: 20,
offset: 10
},
filter: {
id: 1
},
fields: ['id', 'name'],
sort: '-id',
include: {
profile: true
}
};
const query = buildQuery(record);
let response = await getAPIUsers(record);
})();
The next examples will be about how to parse and validate the transformed QueryRecord<T>
on the backend side.
Parsing 🔎
For explanation proposes how to use the query utils,
two simple entities with a simple relation between them are declared to demonstrate their usage:
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToOne,
JoinColumn
} from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn({unsigned: true})
id: number;
@Column({type: 'varchar', length: 30})
@Index({unique: true})
name: string;
@Column({type: 'varchar', length: 255, default: null, nullable: true})
email: string;
@OneToOne(() => Profile)
profile: Profile;
}
@Entity()
export class Profile {
@PrimaryGeneratedColumn({unsigned: true})
id: number;
@Column({type: 'varchar', length: 255, default: null, nullable: true})
avatar: string;
@Column({type: 'varchar', length: 255, default: null, nullable: true})
cover: string;
@OneToOne(() => User)
@JoinColumn()
user: User;
}
In this example typeorm is used as the object-relational mapping (ORM) and typeorm-extension is used
to apply the parsed request query parameters on the db query and express to handle requests.
Parse - In Detail
import {getRepository} from "typeorm";
import {Request, Response} from 'express';
import {
parseFields,
parseFilters,
parseRelations,
parsePagination,
parseSort,
QueryKey
} from "@trapi/query";
import {
applyParsed
} from "typeorm-extension";
export async function getUsers(req: Request, res: Response) {
const {fields, filter, include, page, sort} = req.query;
const repository = getRepository(User);
const query = repository.createQueryBuilder('user');
const relationsParsed = parseRelations(include, {
allowed: 'profile'
});
const fieldsParsed = parseFields(fields, {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id', 'profile.avatar'],
relations: relationsParsed
});
const filterParsed = parseFilters(filter, {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id'],
relations: relationsParsed
});
const pageParsed = parsePagination(page, {
maxLimit: 20
});
const sortParsed = parseSort(sort, {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id'],
relations: relationsParsed
});
const parsed = applyParsed(query, {
[QueryKey.FIELDS]: fieldsParsed,
[QueryKey.FILTER]: filterParsed,
[QueryKey.INCLUDE]: relationsParsed,
[QueryKey.PAGE]: pageParsed,
[QueryKey.SORT]: sortParsed
});
const [entities, total] = await query.getManyAndCount();
return res.json({
data: {
data: entities,
meta: {
total,
...parsed[QueryKey.PAGE]
}
}
});
}
import {getRepository} from "typeorm";
import {Request, Response} from 'express';
import {
parseQuery,
QueryKey,
QueryParseOutput
} from "@trapi/query";
import {
applyParsed
} from "typeorm-extension";
export async function getUsers(req: Request, res: Response) {
const output: QueryParseOutput = parseQuery(req.query, {
[QueryKey.FIELDS]: {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id', 'profile.avatar']
},
[QueryKey.FILTER]: {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id']
},
[QueryKey.INCLUDE]: {
allowed: ['profile']
},
[QueryKey.PAGE]: {
maxLimit: 20
},
[QueryKey.SORT]: {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id']
}
});
const repository = getRepository(User);
const query = repository.createQueryBuilder('user');
const parsed = applyParsed(query, output);
const [entities, total] = await query.getManyAndCount();
return res.json({
data: {
data: entities,
meta: {
total,
...parsed[QueryKey.PAGE]
}
}
});
}
Parse - Third Party Library
It can even be much easier to parse the query key values, because typeorm-extension
uses @trapi/query
under the hood ⚡.
This is much shorter than the previous example and has less direct dependencies 😁.
import {getRepository} from "typeorm";
import {Request, Response} from 'express';
import {
applyFields,
applyFilters,
applyRelations,
applyPagination,
applySort
} from "typeorm-extension";
export async function getUsers(req: Request, res: Response) {
const {fields, filter, include, page, sort} = req.query;
const repository = getRepository(User);
const query = repository.createQueryBuilder('user');
const relations = applyRelations(query, include, {
defaultAlias: 'user',
allowed: ['profile']
});
applySort(query, sort, {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id'],
relations: includes
});
applyFields(query, fields, {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id', 'profile.avatar'],
relations: includes
})
applyFilters(query, filter, {
defaultAlias: 'user',
allowed: ['id', 'name', 'profile.id'],
relations: includes
});
const pagination = applyPagination(query, page, {maxLimit: 20});
const [entities, total] = await query.getManyAndCount();
return res.json({
data: {
data: entities,
meta: {
total,
...pagination
}
}
});
}
Functions
buildQuery
▸ function
buildQuery<T
>(record: QueryRecord<T>
, options?: QueryBuildOptions
): string
Build a query string from a provided QueryRecord.
Example
Simple
import {buildQuery, QueryKey} from "@trapi/query";
type User = {
id: number,
name: string,
age?: number
}
const query: string = buildQuery<T>({
[QueryKey.FIELDS]: ['+age'],
[QueryKey.FILTER]: {
name: '~pe'
}
});
console.log(query);
Type parameters
Name | Description |
---|
T | A type, interface, or class which represent the data structure. |
Parameters
Name | Type | Description |
---|
record | QueryRecord <T > | Query specification more. |
options | QueryBuildOptions | Options for building fields, filter, include, ... |
Returns
string
The function return a string, which can be parsed with the parseQuery function.
I.e. /users?page[limit]=10&page[offset]=0&include=profile&filter[id]=1&fields[user]=id,name
parseQuery
▸ function
parseQuery<T
>(input: QueryParseInput
, options?: QueryParseOptions
): QueryParseOutput
Parse a query string to an efficient data structure ⚡. The output will
be an object with each possible value of the QueryKey enum as property key and the
parsed data as value.
Example
Simple
import {
FieldOperator,
FilterOperator,
parseQuery,
QueryParseOutput
} from "@trapi/query";
const output: QueryParseOutput = parseQuery({
[QueryKey.FIELDS]: ['+age'],
[QueryKey.FILTER]: {
name: '~pe'
}
});
console.log(output);
Type parameters
Parameters
Name | Type | Description |
---|
input | QueryParseInput | Query input data passed i.e. via URL more. |
options | QueryParseOptions | Options for parsing fields, filter, include, ... more |
Returns
QueryParseOutput
The function returns an object.
Types
Key
QueryKey
export enum QueryKey {
FILTER = 'filter',
FIELDS = 'fields',
SORT = 'sort',
INCLUDE = 'include',
PAGE = 'page'
}
QueryKeyParseOptions
export type QueryKeyParseOptions<T extends QueryKey> = T extends QueryKey.FIELDS ?
FieldsOptions :
T extends QueryKey.FILTER ?
FiltersOptions :
T extends QueryKey.INCLUDE ?
IncludesOptions :
T extends QueryKey.PAGE ?
PaginationOptions :
T extends QueryKey.SORT ?
SortOptions :
never;
Record
QueryRecord
export type QueryRecord<R extends Record<string, any>> = {
[K in QueryKey]?: QueryRecordType<K,R>
}
export type QueryRecordType<
T extends QueryKey,
R extends Record<string, any>
> = T extends QueryKey.FIELDS ?
FilterRecord<R> :
T extends QueryKey.FILTER ?
FieldRecord<R> :
T extends QueryKey.INCLUDE ?
IncludeRecord<R> :
T extends QueryKey.PAGE ?
PaginationRecord<R> :
T extends QueryKey.SORT ?
SortRecord<R> :
never;
QueryRecordParsed
export type QueryRecordParsed<T extends QueryKey> =
T extends QueryKey.FIELDS ?
FieldsParsed :
T extends QueryKey.FILTER ?
FiltersParsed :
T extends QueryKey.INCLUDE ?
RelationsParsed :
T extends QueryKey.PAGE ?
PaginationParsed :
T extends QueryKey.SORT ?
SortParsed :
never;
Parse Types
QueryParseInput
export type QueryParseOutput = {
[K in QueryKey]?: any
}
QueryParseOutput
export type QueryParseOutput = {
[K in QueryKey]?: QueryRecordParsed<K>
}
FieldsParsed
export enum FieldOperator {
INCLUDE = '+',
EXCLUDE = '-'
}
export type FieldsParsedElement = ParsedElementBase<QueryKey.FIELDS, FieldOperator>;
export type FieldsParsed = FieldsParsedElement[];
The type looks like this from the structure:
{
// relation/resource alias
alias?: string,
// field name
key: string,
// '+' | '-'
value?: FieldOperator
}
FiltersParsed
export enum FilterOperatorLabel {
NEGATION = 'negation',
LIKE = 'like',
IN = 'in'
}
export type FiltersParsedElement = ParsedElementBase<QueryKey.FILTER, FilterValue<string | number | boolean | null>> & {
operator?: {
[K in FilterOperatorLabel]?: boolean
}
};
export type FiltersParsed = FiltersParsedElement[];
{
// relation/resource alias
alias?: string,
// filter name
key: string,
// {in: ..., ...}
operator?: {
[K in FilterOperatorLabel]?: boolean
},
value: FilterValue<string | number | boolean | null>
}
RelationsParsed
export type RelationParsedElement = ParsedElementBase<QueryKey.INCLUDE, string>;
export type RelationsParsed = RelationParsedElement[];
The type looks like this from the structure:
{
// relation relative depth path
key: string,
// relation alias
value: string
}
export type PaginationParsed = {
limit?: number,
offset?: number
};
SortParsed
export enum SortDirection {
ASC = 'ASC',
DESC = 'DESC'
}
export type SortParsedElement = ParsedElementBase<QueryKey.SORT, SortDirection>;
export type SortParsed = SortParsedElement[];
The type looks like this from the structure:
{
// resource/relation alias
alias?: string,
// field name
key: string,
// 'ASC' | 'DESC'
value: SortDirection
}
ParseOptions Types
QueryParseOptions
export type QueryParseOptions = {
[K in QueryKey]?: QueryKeyParseOptions<K> | boolean
}
FieldsParseOptions
export type FieldsParseOptions = ParseOptionsBase<QueryKey.FIELDS, Record<string, string[]> | string[]>;
The type looks like this from the structure:
{
aliasMapping?: Record<string, string>,
allowed?: Record<string, string[]> | string[],
relations?: RelationsParsed,
defaultAlias?: string
}
FiltersParseOptions
export type FiltersParseOptions = ParseOptionsBase<QueryKey.FILTER>
The type looks like this from the structure:
{
aliasMapping?: Record<string, string>,
allowed?: string[],
relations?: RelationsParsed,
defaultAlias?: string
}
export type PaginationParseOptions = ParseOptionsBase<QueryKey.PAGE> & {
maxLimit?: number
};
The type looks like this from the structure:
{
maxLimit?: number
}
RelationsParseOptions
export type RelationsParseOptions = ParseOptionsBase<QueryKey.SORT, string[] | string[][]>;
The type looks like this from the structure:
{
aliasMapping?: Record<string, string>,
allowed?: string[],
defaultAlias?: string,
includeParents?: boolean | string[] | string
}
SortParseOptions
export type SortOptions = ParseOptionsBase<QueryKey.SORT, string[] | string[][]>;
The type looks like this from the structure:
{
aliasMapping?: Record<string, string>,
allowed?: string[] | string[][],
defaultAlias?: string
relations?: RelationsParsed
}