Warp is an ORM for the scalable web.
With Warp
, you can:
- Define your
tables
- Describe how your
columns
are parsed and validated - Determine
triggers
that run before or after database operations are made - Build
queries
to find the data that you need - Run
functions
that handle complex logic - Restrict access based on
user
details - Implement a
restful
API using anexpress
middleware
NOTE: Currently, only
mysql
is supported. But database adapters for other databases are coming.
NOTE: This documentation is only for versions 6+. For versions 5.* or legacy versions (i.e. versions < 5.0.0), see readme-v5.md or readme-legacy.md
- Installation
- Getting Started
- Classes
- Users
- Objects
- Queries
- Collections
- Triggers
- Functions
- Restful API
To install Warp
, use the npm install
command.
npm install --save warp-server
As Warp
uses advanced javascript features, you must transpile your project using a tool like typescript.
For typescript
, Make sure to add the following in your tsconfig.json
"compilerOptions".
"experimentalDecorators": true,
"emitDecoratorMetadata": true
Also, you need to install and import the reflect-metadata
library in order for Warp
to properly define classes.
NOTE: Remember to import
reflect-metadata
only once in your project, ideally in your mainindex.ts
file.
npm install --save reflect-metadata
import 'reflect-metadata';
To start using Warp
, we need to create an instance of it.
import Warp from 'warp-server';
const databaseURI = 'mysql://youruser:password@yourserver.com:3306/yourdatabase?charset=utf8';
const service = new Warp({ databaseURI });
// Inititialize the service
service.initialize().then(() => { /** Start using the service here */ });
In the example above, we created a new Warp
service. We also defined how we would connect to our database by setting the databaseURI
configuration.
TIP: The
initialize()
method is asynchronous. Aside from using.then()
, we can also useawait
.
import Warp from 'warp-server';
const databaseURI = 'mysql://youruser:password@yourserver.com:3306/yourdatabase?charset=utf8';
const service = new Warp({ databaseURI });
// Wrap the service in an asynchronous function
(async () => {
// Inititialize the service
await service.initialize();
/** Start using the service here */
})();
Aside from databaseURI
, there are other options that we can configure for our Warp
instance.
Name | Format | Description |
---|---|---|
databaseURI | URI | URI of your database (required) |
persistent | boolean | State whether db pool connections are recycled or auto-disposed (default: false ) |
restful | boolean | State whether you want to use the REST API feature |
apiKey | string | API key for your REST API (required if restful is true) |
masterKey | string | Master key for your REST API (required if restful is true), NOTE: Only admin users should know this key |
customResponse | boolean | State whether the response is going to be handled by Warp or passed to the next middleware (default: false ) |
The URI format follows the syntax below.
databaseURI: 'protocol://user:password@server:port/database?optionalParamter1=value&optionalParamter2=value'
By using a URI, we are able to define the connection definition, all in one string. Additionally, if you would like to use multiple connections for your application, such as defining master and slave databases, you can set the value as an array.
databaseURI: [
{
uri: 'protocol://user:password@server:port/database?optionalParamter1=value',
action: 'write'
},
{
uri: 'protocol://user:password@server:port/database?optionalParamter1=value&optionalParamter2=value',
action: 'read'
}
]
Note the other property called action
that determines which sort of database operations are assigned to this connection. This can either be read
or write
. It is adivsable to only have one write
connection. However, you can have multiple read
connections.
databaseURI: [
{
uri: 'protocol://user:password@server:port/database?optionalParamter1=value',
action: 'write'
},
{
uri: 'protocol://user:password@server:port/database?optionalParamter1=value&optionalParamter2=value',
action: 'read'
},
{
uri: 'protocol://user:password@server:port/database?optionalParamter1=value&optionalParamter2=value',
action: 'read'
}
]
Now that we've initialized Warp
, we can now start using it!
A Class
is a representation of a table
inside the database. Inside the Class
are keys
, which represent how columns in the database are parsed and formatted.
For example, a dog
table will have a corresponding class called Dog
that has different keys
such as name, age, height, and weight.
Among these keys
are three special ones that are automatically set by the server and cannot be manually edited.
id
: a unique identifier that distinguishes an object inside a tablecreatedAt
: a timestamp that records the date and time when an object was created (UTC)updatedAt
: a timestamp that records the date and time when an object was last modified (UTC)
NOTE: Be sure to have
id
,created_at
, andupdated_at
fields in your table to avoid conflicts.
NOTE: Aside from the three keys above, you also need to make sure that the table has a
deleted_at
field for deletion operations.
To create a Class, simply extend from Warp.Class
.
import { Class, define } from 'warp-server';
@define class Dog extends Class { }
In the example above, you can see that we defined a new class called Dog
. Using the @define
decorator, we tell Warp
that the table name for this is dog
(i.e., The snake_case
version of the class name).
If we wanted the class name to be different from the table name, we can define it manually.
@define({ className: 'dog', source: 'canine' })
class Dog extends Class { }
We now have a class name of dog
that points to a table called canine
. The class name is the one used to define the routes in our restful API
. For more information, see the restful API section.
Now, let's add in some keys
to our Class
.
import { Class, define, key } from 'warp-server';
@define class Dog extends Class {
@key name: string;
@key age: number;
@key height: number;
@key weight: number;
bmi: number;
}
In order to add our keys, we defined them as properties inside the Class
. It's worth mentioning that we used the @key
decorator to tell Warp
that the properties are columns. Otherwise, they would simply be ignored during database operations.
In this example, since the bmi
proeprty is not defined as a key, then it is ignored when we're saving, destroying or querying Dog
.
Aside from informing Warp
that the keys
are columns, the @key
decorator also infers the data type of the field based on its typescript
type. By doing so, it validates and parses the fields automatically.
NOTE: You do not need to include id, createdAt, and updatedAt keys because they are already defined in
Class
.
NOTE: We used
camelCase
format for the properties. Inside the database, we expect the columns to be insnake_case
format. If this were shown as a table, it would look similar to the following.
Table: Dog
id | name | age | height | weight | created_at | updated_at | deleted_at |
---|---|---|---|---|---|---|---|
1 | Bingo | 4 | 1.5 | 43.2 | 2018-03-09 12:38:56 | 2018-03-09 12:38:56 | null |
2 | Ringo | 5 | 1.25 | 36 | 2018-03-09 12:38:56 | 2018-03-09 12:38:56 | null |
If the database column name is different from the property name of the Class
, you can define it using the from
option.
import { Class, define, key } from 'warp-server';
@define class Dog extends Class {
@key({ from: 'nickname' })
nick: string;
}
Also, if you want to manually define the type of the key
instead of relying on type inference, you can use the type
option.
import { Class, define, key } from 'warp-server';
interface FoodPreferences {
taste: string;
cuisine: string;
}
@define class Dog extends Class {
@key({ type: 'json' })
preferences: FoodPreferences;
@key({ type: 'array' })
pseudonyms: string[];
}
One of the biggest features of relational databases is their ability to define relations
between tables. This makes it more convenient to link and retrieve entities.
If two tables have a one-to-many
relation, we can define the type of the key
with an instance of a Class
. This type allows us to define from which class our key
belongs to.
Later on, when we're querying, the key will automatically return an instance of the Class
that we defined. Additionally, it validates whether the value we set to our key
matches the correct Class
.
import { Class, key } from 'warp-server';
@define class Department extends Class { /** shortened for brevity */ }
@define class Employee extends Class {
@key name: string;
@key department: Department;
}
In the example above, we tell Warp
that our department
key belongs to the Department
class.
Inside our database, every time we save or query Employee
, it automatically maps the column employee.department_id
to department.id
.
If you want to define a different column for the mapping, you can set it using the from
and to
options.
import { Class, key } from 'warp-server';
@define class Department extends Class { /** shortened for brevity */ }
@define class Employee extends Class {
@key name: string;
@key({ from: 'employee.deparment_code', to: 'department.code' })
department: Department;
}
Now that we've defined our relation, we can start using it in our code.
Below is an example of a query with a relation
. For more information on queries, see the Queries section.
const service = new Warp({ /** some configuration **/ });
// Create a query
const employeeQuery = new Query(Employee).include('department.name');
// Get employee
const employee = await service.classes.first(employeeQuery);
// employee.department is an instance of the `Department` class
// so we can even retrieve the department's name
const departmentName = employee.department.name;
Another example can be found below, this time it's about saving objects. For more information on saving and destroying objects, see the Objects section.
const employee = new Employee;
employee.department = new Department(1); // OK
employee.department = new Country(3); // This will cause an error
await service.classes.save(employee);
To enhance how keys are validated, parsed, and formatted, we can add Key Modifiers
.
@hidden
If you use the restful API
and want to hide a key from the results, you can use the @hidden
decorator.
import { Class, define, key, hidden } from 'warp-server';
@define class Dog extends Class {
@key name: string;
@hidden @key secretName: string; // Will be omitted from query results
}
NOTE:
secretName
can still be retrieved in theClass
object. Only the results in therestful API
, and the result ofdog.toJSON()
will have it hidden.
If you want to guard the key from being updated over the restful API
or via the Class
constructor, you can use the @guarded
decorator.
import { Class, define, key, guarded } from 'warp-server';
@define class Dog extends Class {
@key name: string;
@guarded @key eyeColor: string;
}
const daschund = new Dog;
dog.eyeColor = 'green'; // OK
const corgi = new Dog({ eye_color: 'brown' }); // Will throw an error
Sometimes, you have keys
that are computed which are, hence, not stored in the database. If you want to display a key in the restful API
, you can use the @computed
decorator.
NOTE: Once a key is defined as
@computed
, you cannot manually set its value
import { Class, define, key, computed } from 'warp-server';
@define class Dog extends Class {
@key name: string;
@key weight: number;
@key height: number;
@computed
@key get bmi(): number {
return this.weight / (this.height * this.height);
}
}
const daschund = new Dog;
dog.height = 1.5;
dog.weight = 35;
const bmi = dog.bmi; // Will return weight/(height * height)
dog.bmi = 32; // Will throw an error
In the restful API
, the value will also be included.
{
"result": [
{
"id": 42,
"height": 1.5,
"weight": 35,
"bmi": 15.555555555555555
}
]
}
If you want to limit the length of a string key, you can use the @length
decorator.
import { Class, define, key, length } from 'warp-server';
@define class Dog extends Class {
@length(3) @key name: string; // Minimum of 3 characters
@length(0, 5) @key alias: string; // Maximum of 5 characters
@length(3, 5) @key code: string; // Between 3 to 5 characters
}
If you want to limit the range of values for a number key, you can use the @min
, @max
, and @between
decorators.
import { Class, define, key, min, max, between } from 'warp-server';
@define class Dog extends Class {
@min(3) @key age: number; // Minimum value of 3
@max(5) @key height: number; // Maximum value of 5
@between(3, 5.5) @key weight: number; // Between 3 to 5.5
}
If you want to add rounding to the value of a number key, you can use the @rounded
decorator.
import { Class, define, key, range } from 'warp-server';
@define class Dog extends Class {
@rounded(2) @key weight: number; // Round to 2 decimals places
}
Additionally, you can specify the rounding rule
to be used. This can either be off
, up
, or down
. The default rule is off
.
@define class Dog extends Class {
@rounded(2) @key weight: number;
@rounded(2, 'up') @key height: number;
@rounded(2, 'down') @key rating: number;
}
const corgi = new Dog;
corgi.weight = 22.365; // 22.37
corgi.height = 1.152; // 1.16
corgi.rating = 4.318; // 4.31
If you want to check if a key
's value is one of pre-defined values, you can use the @enumerated
decorator.
import { Class, define, key, enumerated } from 'warp-server';
@define class Dog extends Class {
@enumerated(['active', 'inactive']) // Can be an array of values
@key status: string;
@enumerated(new Map([[1, 'basic'], [2, 'intermediate'], [3, 'advanced']])) // Can be a Map
@key trainingLevel: number | string;
}
const corgi = new Dog;
corgi.status = 'active'; // OK
corgi.status = 'deactivated'; // throws an Error
corgi.trainingLevel = 'basic'; // throws an Error
corgi.trainingLevel = 1; // OK
const trainingLevel = corgi.trainingLevel; // returns 'basic';
Now that we've created our Class
, we can start using it in our application. For more information, see the Objects section.
However, if you are using the restful API
feature and want the class to be recognized, you need to register it.
To do so, simply use the classes.register()
method.
const service = new Warp({ /** some configuration **/ });
@define class Dog extends Class { /** shortened for brevity **/ }
service.classes.register({ Dog });
The classes.register()
accepts a mapping of classes, so you can add several classes at once.
@define class Dog extends Class { /** shortened for brevity **/ }
@define class Cat extends Class { /** shortened for brevity **/ }
service.classes.register({ Dog, Cat });
In most applications, a user
table is usually defined and is often used to authenticate whether certain parts of the app are accessible. For Warp
there is a built-in User
class that we can extend from to define our user
table.
To define a user, simply extend the Warp.User
class.
import Warp, { define, key } from 'warp-server';
@define class User extends Warp.User {
@key firstName: string;
@key lastName: string;
}
Because User
is a special class, it has pre-defined keys
that are helpful for authentication and authorization.
username
: a unique string used for authemail
: a valid email address that can be used for verificationpassword
: a secret string used for auth
In addition, the User
class has built-in Triggers
that check whether the supplied username
and email
keys are valid and unique. It also prevents users from retrieving the raw password
field, as well as ensuring that database operations to the user
are only made by the user
itself or by administrators using master
mode.
Starting from version 6.0.0, Warp
no longer implements its own auth mechanism. However, this now opens up an opportunity for developers to make use of other popular and stable implementations such as passport, OAuth2, and OpenID Connect.
Ideally, you would use an auth
library or middleware to authenticate and retrieve the user from the database. Afterwards, you can map the user identity to your defined User
class and use this class for database operations.
// Define Warp service
const service = new Warp({ /** some configuration **/ });
// Define User
class User extends Warp.User { /** shortened for brevity **/ }
// A middleware that maps req.user before reaching service.router
const mapUser = (req, res, next) => {
// We assume req.user is an object containing user details
req.user = new User(req.user);
next();
};
// Use the middleware
req.use('/api/', someAuthMiddleware, mapUser, service.router);
By default, the restful API
tries to get the req.user
parameter
An Object
is the representation of a single row
inside a table.
For example, a Dog
class can have an instance of an Object
called corgi
, that has different properties such as name
, age
, and weight
.
To create an Object
, simply instantiate the Class
.
// Define the class
@define class Dog extends Class {
@key name: string;
@key age: number;
@key height: number;
@key weight: number;
@key awardsWon: number;
@key owner: Person;
get bmi(): number {
return this.weight / (this.height * this.height);
}
}
// Instantiate the class
const corgi = new Dog;
We can set the values of its keys
using the properties we defined.
corgi.name = 'Bingo';
corgi.age = 5;
corgi.weight = 32.5;
corgi.owner = new Person(5); // person with id `5`
It also validates if we provide wrong data types.
corgi.weight = 'heavy'; // This will cause a validation error
Alternatively, we can define the values of keys
inside the object constructor.
const corgi = new Dog({ name: 'Bingo', age: 5, weight: 32.5 });
TIP: The validation of data types still works when using the object constructor approach. Also,
@guarded
keys will throw an error if you try to assign them using this approach.
Once we've finished setting our keys
, we can now save the Object
using the classes.save()
method.
// The save method is a promise, so we can await it
await service.classes.save(corgi);
NOTE: Don't forget to
initialize()
before runningclasses.save()
methods.
The classes.save()
method inserts a new row if the object was just newly created. If, however, the object already exists (i.e. has an id
), then it will update instead the values inside the row.
// Prepare the object
const corgi = new Dog;
corgi.name = 'Bingo';
corgi.age = 5;
corgi.weight = 32.5;
// Save the object
await service.classes.save(corgi);
// Change a Key
corgi.weight = 35;
// Update the object
await corgi.save();
Alternatively, if we know the id
of the row we want to update, use the withId()
method.
const daschund = Dog.withId<Dog>(25); // id is 25
daschund.name = 'Brownie';
// Update the object
await service.classes.save(daschund);
Or simply pass the id inside the Class
constructor.
const shitzu = new Dog(16); // id is 16
shitzu.name = 'Fluffy';
// Update the object
await service.classes.save(shitzu);
Or pass the id
along with other keys
inside the constructor.
const beagle = new Dog({ id: 34, name: 'River' });
// Update the object
await service.classes.save(daschund);
If the key we are trying to update is defined as a number
and we want to atomically increase or decrease its value without knowing the original value, we can opt to use the .increment()
method.
For example, if we want to increase the age by 1, we would use the following code.
// Increase awardsWon by 1
corgi.increment('awards_won', 1);
Conversely, if we want to decrease a number
key, we would use a negative value.
// Decrease the weight by 5.2
corgi.increment('weight', -5.2);
Recently, relational databases have slowly introducted JSON data structures into their systems. This allows for more complex use cases which might have originally needed a NoSQL database to implement.
NOTE: JSON data types are still in its early stages and performance has not yet been thoroughly investigated yet. Hence, only place mid to shallow JSON structures inside your databases for the time being.
If the node of the JSON data we want to modify is an object, we can use the json().set()
method to update its value.
// Define class
@define class Dog extends Class {
/** other keys **/
@key preferences: object;
}
// Create a new object
const labrador = new Dog;
labrador.preferences = { food: 'meat' };
// Save the object
await service.classes.save(labrador);
// Change preferences
labrador.json('preferences').set('$.food', 'vegetables');
// Update the object
await service.classes.save(labrador);
Notice the first argument of the set()
method. This represents the path
of the JSON column that we want to modify. For more information, see the documentation of path syntax on the MySQL website.
If the node of the JSON data we want to edit is an array, we can use the json().append()
method to add to its value.
labrador.preferences = { toys: ['plushie', 'ball'] };
// Save the object
await service.classes.save(labrador);
// Append a new toy
labrador.json('preferences').append('$.toys', 'bone');
// Update the object
await service.classes.save(labrador);
By default, Warp
assumes that tables have an auto-increment id
. However, if you want to manually define the value of the id
, you need to use the .setNewId()
method.
const dog = new Dog;
dog.setNewId('D39t2h28-ug4822G-K24u5H4-24mU24');
await service.classes.save(dog);
You can also use .setNewId()
for existing objects.
const dog = new Dog('123-456-789');
dog.setNewId('789-012-345');
await service.classes.save(dog);
const id = dog.id; // 789-012-345
IMPORTANT: Make sure to use
.setNewId()
for manually setting theid
. Setting theid
with the following methods will not work.
dog.id = 3; // Will throw an error because id is readonly
const corgi = new Dog(3); // Will assume `3` already exsists in the table so `.save()` will only update the row with id `3` instead of creating a new row
const shitzu = new Dog({ id: 'abc-123' }); // Will assume `abc-123` already exists in the table so `.save()` will only update the row with id `abc-123` instead of creating a new row
As an example of how .setNewId()
might be useful, below, you can use the uniqid
library and also set a beforeSave
trigger in order to programmatically create new id
's.
import uniqid from 'uniqid';
class Dog extends Class {
@beforeSave
createNewId() {
if(this.isNew) this.setNewId(uniqid());
}
}
If we want to delete an Object
, we can use the classes.destroy()
method.
await service.classes.destroy(labrador);
NOTE: In
Warp
, there is no hard delete, only soft deletes. Whenever an object is destroyed, it is preserved, but itsdeleted_at
column is set to the current timestamp. During queries, the "deleted" objects are omitted from the results automatically. You do not need to filter them out.
Now that we have a collection of Objects
inside our database, we would need a way to retrieve them. For Warp
, we do this via Queries
.
To create a query, wrap the Class
inside a Query
.
import { Class, define, Query } from 'warp-server';
@define class Dog extends Class { /** shortened for brevity */ }
// Define the query
const dogQuery = new Query(Dog);
Once created, we can fetch the results via classes.find()
.
const dogs = await service.classes.find(dogQuery);
We now have a Collection
of Dog
objects. This collection helps us iterate through the different rows of the dog
table. To learn more about collections, see the Collections section.
By default, Warp
fetches all of the visible keys
in a Class
(i.e. keys not marked as @hidden
).
However, if we consider performance and security, it is recommended that we pre-define the keys
we would like to fetch. This helps reduce the size of the data retrieved from the database, and reduce the scope of the data accessed.
To define the keys
you want to fetch, use the select()
method.
// You can pass a single value
dogQuery.select('name');
// You can also pass an array
dogQuery.select(['name', 'age']);
// Or you can define multiple argumetns
dogQuery.select('name', 'age', 'weight');
// Or you can chain multiple select methods
dogQuery.select('name').select('age').select('weight');
If you want to include keys
from relation
keys. You must include them in the select()
method. Otherwise, they won't be fetched from the database.
dogQuery.select('location.id', 'location.name');
If, on the other hand, you plan on fetching all visible keys
, and include relation
keys, you can use the include()
method instead of having to call the select()
method on all the keys
.
dogQuery.include('location.name', 'location.address');
NOTE: If you are already using
select
, there is no need to useinclude
. You must put allkeys
insideselect
.
Constraints help filter the results of a query. In order to pass constraints, use any of the following methods.
// Prepare query
const dogQuery = new Query(Dog);
// Find an exact match for the specified key
dogQuery.equalTo('name', 'Bingo');
dogQuery.notEqualTo('name', 'Ringo');
// If the key is ordinal (i.e. a string, a number or a date), you can use the following constraints
dogQuery.lessThan('age', 21);
dogQuery.lessThanOrEqualTo('name', 'Zack');
dogQuery.greaterThanOrEqualTo('weight', 30);
dogQuery.greaterThan('created_at', '2018-03-12 17:30:00');
// If you need to check if a field is null or not null
dogQuery.exists('breed');
dogQuery.doesNotExist('breed');
// If you need to check if a given key is found in a list, you can use the following constraints
dogQuery.containedIn('breed', ['Malamute', 'Japanese Spitz']);
dogQuery.containedInOrDoesNotExist('breed', ['Beagle', 'Daschund']);
dogQuery.notContainedIn('age', [18, 20]);
// If you need to check if a key contains a string
dogQuery.startsWith('name', 'Bing');
dogQuery.endsWith('name', 'go');
dogQuery.contains('name', 'in');
// If you need to check if a key contains several substrings
dogQuery.containsEither('description', ['small','cute','cuddly']);
// If you need to check if a key contains all substrings
dogQuery.containsAll('name', ['big','brave','trustworthy']);
TIP: Each constraint returns the query, so you can chain them, such as the following.
const dogQuery = new Query(Dog)
.greaterThanOrEqualTo('age', 18)
.contains('name', 'go')
.containedIn('breed', ['Malamute', 'Japanse Spitz']);
The constraints above are usually enough for filtering queries; however, if the scenario calls for a more complex approach, you may nest queries within other queries.
For example, if we want to retrieve all the dogs who are residents of urban cities, we may use the .foundIn()
method.
// Prepare subquery
const urbanCityQuery = new Query(Location).equalTo('type', 'urban');
// Prepare main query
const dogQuery = new Query(Dog)
.foundIn('location.id', 'id', urbanCityQuery);
// Get dogs
const dogs = await service.classes.find(dogQuery);
If we want to see if a value exists in either of multiple queries, we can use .foundInEither()
.
// Prepare subqueries
const urbanCityQuery = new Query(Location).equalTo('type', 'urban');
const ruralCityQuery = new Query(Location).equalTo('type', 'rural');
// Prepare main query
const dogQuery = new Query(Dog)
.foundInEither('location.id', [
{ 'id': urbanCityQuery },
{ 'id': ruralCityQuery }
]);
// Get dogs
const dogs = await service.classes.find(dogQuery);
If we want to see if a value exists in all of the given queries, we can use .foundInAll()
.
// Prepare subqueries
var urbanCityQuery = new Query(Location).equalTo('type', 'urban');
var smallCityQuery = new Query(Location).equalTo('size', 'small');
// Prepare main query
var dogQuery = new Query(Dog)
.foundInAll('location.id', [
{ 'id': urbanCityQuery },
{ 'id': smallCityQuery }
]);
// Get dogs
const dogs = await service.classes.find(dogQuery);
Conversely, you can use .notFoundIn()
, .notFoundInEither()
, and .notFoundInAll()
to retrieve objects whose key is not found in the given subqueries.
By default, Warp
limits results to the top 100
objects that satisfy the query criteria. In order to increase the limit, we can specify the desired value via the .limit()
method.
dogQuery.limit(1000); // Top 1000 results
Also, in order to implement pagination for the results, we can combine .limit()
with .skip()
. The .skip()
method indicates how many items are to be skipped when executing the query. In terms of performance, we suggest limiting results to a maximum of 1000
and use skip to determine pagination.
dogQuery.limit(10).skip(20); // Top 10 results; Skip the first 20 results
dogQuery.limit(1000); // Top 1000 results
dogQuery.skip(1000); // Skip 1000 results
TIP: We recommend using the sorting methods in order to retrieve predictable results. For more info, see the section below.
Sorting determines the order by which the results are returned. They are also crucial when using the limit and skip parameters. To sort the query, use the following methods.
dogQuery.sortBy('age'); // Sorts the query by age, in ascending order
dogQuery.sortByDescending(['created_at', 'weight']); // You can also use an array to sort by multiple keys
// You can also enter the keys as separate parameters
dogQuery.sortByDescending('crated_at', 'weight');
When using queries, the result returned is a Collection
of Objects
. Collections
are a special iterable for Warp
that allows you to filter, sort and manipulate list items using a set of useful methods.
To count the results, use the length
property.
// Prepare query
const dogQuery = new Query(Dog);
// Get dogs
const dogs = await service.classes.find(dogQuery);
// Gets the total count
const total = dogs.length;
To filter the results and return a new collection based on these filters, use the following methods.
// Returns the first Object
const firstDog = dogs.first();
// Returns the last Object
const lastDog = dogs.last();
// Returns a new collection of objects that return true for the given function
const oldDogsOnly = dogs.where(dog => dog.age > 12);
To manipulate the results, use the following methods.
// Loops through each Object and applies the given function
dogs.forEach(dog => console.log(`I am ${dog.name}`));
// Returns an array of whatever the given function returns
const names = dogs.map(dog => dog.name);
// Loops through each Object and asynchronously executes every function one after the other
dogs.each(dog => service.classes.destroy(dog));
// Loops through each Object and asynchronously executes all functions in parallel
dogs.all(dog => service.classes.destroy(dog));
// Iterate through every Object
for(const dog of dogs) {
console.log(`I am ${dog.name} and my owner is ${dog.owner.name}`);
}
Oftentimes, you may opt to use native data types to handle Objects. To accomodate this, Collections contain the following methods.
// Returns an array of Objects
const dogArray = dogs.toArray();
// Returns an array of object literals
const dogJSON = dogs.toJSON();
// Returns a Map of Objects mapped by `id`
const dogMappedById = dogs.toMap();
// Returns a Map of Objects mapped by `name`
const dogMappedByName = dogs.toMap('name');
// Returns a Map of Objects mapped by `owner.id`
const dogMappedByOwner = dogs.toMap(dog => dog.owner.id);
TIP: Since some methods return new Collections, you can chain several methods together, as needed.
// Prepare query
const dogQuery = new Query(Dog);
// Get dogs
const dogs = await service.classes.find(dogQuery);
// Find corgis, and return their names
const firstCorgiNames = dogs.where(dog => dog.breed === 'corgi')
.map(dog => dog.name);
If a Class
needs to be manipulated before or after it is queried, saved, or destroyed, you can use Triggers
.
Triggers
allow you to specify which methods must be executed when certain events occur. You can consider these as hooks to your classes where you can perform additional logic outside of the basic parsing and formatting of Warp
.
To make sure a method is run before the class is saved (whether created or updated), describe it with @beforeSave()
.
@define class Dog extends Class {
/** some keys **/
// You can validate input
@beforeSave
validateAge() {
if(this.age > 30) throw new Error('This dog is too old!');
}
// You can change key values
@beforeSave
convertWeight() {
this.weight = this.weight * 2.2;
}
// You can set default values
@beforeSave
setDefaultDescription() {
if(this.isNew) { // If you are creating a new Dog
this.description = 'I am cute dog.';
}
}
// You can update other Objects
@beforeSave
async updateOwner(classes) {
if(this.isNew) {
const owner = this.owner;
classes.increment(owner, 'dog_count', 1);
await classes.save(owner);
}
}
// You can check access
@beforeSave
async checkAccess(classes, { user, master }) {
if(!this.isNew && this.owner.id !== user.id || !master) {
throw new Error('Only owners of dogs, or administrators can edit their info');
}
}
}
To make sure a method is run after the class is saved (whether created or updated), describe it with @afterSave()
.
NOTE: Since these functions are run in the background, errors thrown here will not affect the program. Hence, it is better to catch them and log them.
@define class Dog extends Class {
/** some keys **/
// Throwing an error will not stop the program
@afterSave
uselessError() {
if(this.age > 30) throw new Error('This will not stop the program');
}
// You can save other Objects
@afterSave
async addNewPet(classes) {
if(this.isNew) {
const pet = new Pet;
pet.dog = this;
await classes.save(pet);
}
}
// You can send a notification
@afterSave
async sendNotification(classes, { user }) {
SomeService.Notify('You have successsfully saved a dog!', user.email);
}
}
To make sure a method is run before the class is destroyed, describe it with @beforeDestroy()
.
@define class Dog extends Class {
/** some keys **/
// You can validate input
@beforeDestroy
validateAge() {
if(this.age < 18) throw new Error('This dog is too young to destroy!');
}
// You can change key values
@beforeDestroy
changeStatus() {
this.status = 'removed';
}
// You can update other Objects
@beforeDestroy
async updateOwner(classes) {
if(this.isNew) {
const owner = this.owner;
classes.increment(owner, 'dog_count', -1);
await classes.save(owner);
}
}
// You can check access
@beforeDestroy
async checkAccess(classes, { user, master }) {
if(!this.isNew && this.owner.id !== user.id || !master) {
throw new Error('Only owners of dogs, or administrators can destroy their info');
}
}
}
To make sure a method is run after the class is destroyed, describe it with @afterDestroy()
.
NOTE: Since these functions are run in the background, errors thrown here will not affect the program. Hence, it is better to catch them and log them.
@define class Dog extends Class {
/** some keys **/
// You can update other Objects
@afterDestroy
async updateOwner(classes) {
if(this.isNew) {
const petQuery = new Query(Pet)
.equalTo('dog.id', this.id);
const pet = classes.first(petQuery);
await pet.destroy();
}
}
// You can send a notification
@afterDestroy
async sendNotification(classes, { user }) {
SomeService.Notify('You have successsfully removed a dog!', user.email);
}
}
To make sure a method is run before the class is fetched, describe it with @beforeFind()
, @beforeFirst
, and @beforeGet
.
@define class Dog extends Class {
/** some keys **/
// You can limit the result
@beforeFind
limitResult(query) {
query.limit(5);
}
// You can put additional constraints
@beforeFirst
removeOldDogs() {
query.greaterThan('age', 10);
}
// You can check access
@beforeGet
async checkAccess(query, { user, master }) {
if(!this.isNew && this.owner.id !== user.id || !master) {
throw new Error('Only owners of dogs, or administrators can get their info');
}
}
}
Ideally, you can perform a multitude of tasks using classes. However, for special operations that you need to perform inside the server, you can use Functions
.
A Function
is a piece of code that can be executed via a named endpoint. It receives input keys that it processes in order to produce a result.
To define a Function, use the Function
class.
// Import Function from Warp Server
import { Function } from 'warp-server';
import getDogsPromise from './get-dogs';
class GetFavoriteDogs extends Function {
// Optional method
static get masterOnly() {
return false;
}
async run(keys) {
// collection_id was passed to the request
const collectionID = keys.collection_id;
// Do some work here...
const favoriteDogs = await getDogsPromise(collectionId);
// Throw an error instead of a result
throw new Error('Cannot get your favorite dogs');
// Return the result
return favoriteDogs;
}
}
For the above example, you can see that we declared a run()
method to execute our logic. This is the only method you need in order to define a function.
However, you might notice the masterOnly
getter declared atop. What this does is basically limit access to the function to masters (i.e. requests made using the X-Warp-Master-Key
). You can omit this code as this defaults to false
.
Right now, the Function
you created is still not recognized by Warp
. To register its definition, use functions.register()
.
// Add the GetFavoriteDogs function
service.functions.register({ GetFavoriteDogs });
// Apply the router after
app.use('/api/1', service.router);
functions.register()
accepts a mapping of Functions
, so you can do the following.
// Add multiple functions
service.functions.register({ GetFavoriteDogs, GetGoodDogs });