perseest
WARNING: As I stated in the previous commits, this project is under heavy development and should not be used in production. Even if I plan to create a stable codebase , for now the API is changing often. If you like perseest, you should stick to a version and update very carefully.
Introduction
The perseest package provides an ES6 mixin (and a base class equivalent) to make (almost) any class and data structure storable in a database, in a fast and painless way. The concept is to add a (thin) layer between a class and the postgres database handler. This allows the developer to handle persistent entities such that:
- The approach is declarative instead of being imperative
- The actual definition of a class or type is completely separated from the way inwhich its instances are made persistent
- If an application module does not need to fetch/store/delete instances in the database, it can use the exact same class definition without knowing anything about these operations
The direct database handling is delegated to the node-postgres package.
Installation
A packaged version of perseest is available on npm at this link. To install it, run:
npm install perseest
Testing
Testing requires:
Given these requirements, all you have to do is to pass a connection URI with
the shell environment variable POSTGRES_URI
; then run npm test
. To verify
the test coverage, run npm run coverage
.
Usage
Making a class persistent
Basically, to make an ES6 class persistent you have to make it extend
Perseest
, using either Perseest.Class
or Perseest.Mixin
, with a static
member named db
being by an instance of Perseest.Config
. For example,
consider a user class which has to save its username, email and hashed
password:
const Perseest = ; // Using Perseest.ClassClass { super; // Perseest constructors do not need any argument thisname = name; thisemail = email; thishash = hash; } ... // Use as Perseest.Config(table_name, primary_key, columns) // Columns are specified as an enumerable object in the form: // { column_name: { attribute1: <some-value>, ... }, ... } static db = 'UserAccount' 'id' id: serial: true name: id: true email: id: true hash: null // {} is the same ; // Moreover, columns can be specified as a [name,attributes] array, and when // there are no options to specify, you can just use a string. // This can be handy with a lot of columns without attributes static db = 'UserAccount' 'id' 'a' 'lot' 'of' 'user' 'fields' 'but' 'just' 'one' 'with' 'attributes' id: true ; // Using Perseest.Mixin { thisname = name; thisemail = email; thishash = hash; } ... static db = 'UserAccount' 'id' id: serial: true name: id: true email: id: true hash: null ;
Column attributes define special behaviours for particular columns:
id
specifies that the column can be used as a univocal idserial
specifies that the column is automatically handled by the database and should not be mentioned in INSERT or UPDATE queries (you can still modify it if you explicitly pass its name toupdate()
)
Default perseest interface
You can use basic, ActiveRecord inspired, methods to interface with the database in a handy way. Assumed that we have a user persistent class, here are some examples:
const user = /* ... */; // Set up the databaseUserdb; // Save the user (falls back to update() if user exists)user; // Update user columnsuser; // All the columns...user; // ...or just a few // Fetch a userconst fetched = User;if fetched === null console;else user; // Delete a useruser; // By instance...User; // ...or by id // Fetch many usersconst many = User; // Require role equals 'admin'const all = User; // Fetch all the users // Delete many usersUser; // Require dangerous equals true
These methods are present by default, and no additional configuration is required.
NOTE: At this development stage, queries acting on multiple instances are very limited. I shall implement a more sophisticated interface later on
Queries
A perseest query is represented by a Perseest.Query
instance, which is
composed of the following fields:
Field | Type | Description |
---|---|---|
name |
string |
Query name |
generate |
Function |
Generate a parameterised query for node-postgres |
transform |
Function |
Transform the query response in a return value |
The actual SQL queries are dynamically generated by functions. If you need
basic INSERT/SELECT/UPDATE/DELETE, perseest is shipped with a set of query
generators which can handle arbitrary table names and columns.
If you need to perform particular operations, you can either define a new query
or use use the exposed PerseestClass.db.pool
(which is an instance of
pg.Pool
, see node-postgres) for a direct
interface the to the database.
IMPORTANT SECURITY NOTE: When you use SQL this way, you basically deal with table and column names and values: with the default query generators, perseest performs parameterised queries, but it does not escapes table and column names. Even if no table or column name not found in the Perseest.Config object will (hopefully) be used, checking the sanity of them is completely up to you.
Query types
Many queries share a similar behaviour. For example, the default delete
,
save
and update
queries perform completely different tasks; in fact, all of
them returns true
if some operation was performed, false
otherwise.
The return value can be a generalizable aspect of queries: query types define
common ways inwhich a node-postgres response can be transformed in a JavaScript
object (or value) to return.
Basically, a query type is identified by a name and a transform function, which
takes a Perseest.Parameters
instance as argument.
The following types are implemented by default:
Type | Description |
---|---|
singular | Transforms a pg response in a single entity instance |
multiple | Transforms a pg response in an array containing multiple entities |
boolean | Returns true if some operation was performed, false otherwise |
counter | Returns the number of rows involved in the query |
User-defined types can be implemented. Let's realize a type which makes a query return the of the columns having the same value for a collection of fetched entities (along with them):
// First, we define our transform function { let retObj = entities: resrows repetitive: ; if resrowslength === 0 return retObj; for const f of resfields const example = resrows0fname; for const x of resrows if xfname !== example break; retObj; return retObj;} // Let's add a query typeSomePerseestentClassdbtypes; // Now we can create queries using that type (after I will explain how queries// can be defined)SomePerseestentClassdbqueries;
Adding queries
Since version 1.4, new queries can be defined with the new query interface. Basically, you need to give:
- a name
- a query generator, i.e. a function generating an object in the form
{ text: 'sql-query-text', values: [parameterized, query, values] }
- either a transformer (i.e. a function taking a QueryParameters instance and returning the candidate return value for the query performer) or the name of an existing query type
Below some examples are given:
// Let's consider a DB table containing some log messagesClass static db = 'Messages' 'id' 'content' 'severity' // e.g.: We want to know if there is any error present in the table// (useless, trivial and stupid example, but will do)try LogMessagedbqueries catch err throw err // We can now call the defined queryLogMessagedbqueries
Of course, we can define hooks also for user-defined queries.
Internal behaviour
Parameters
Naturally, a query needs a context (i.e. some application parameters) to be
executed in a program's life. This is achieved by passing a Perseest.Parameters
object when it is performed, composed of the following fields:
Field | Description |
---|---|
conf |
Perseest.Config instance for the persistent class |
ent |
Entity instance (single) |
entities |
Entity instances (multiple) |
key |
Name of the column used as univocal id |
kval |
Value for key |
columns |
Names of the involved columns |
values |
Values corresponding to columns , in the same order |
Such instance lives for the whole execution of the query, being passed also to hooks. Moreover, other fields can be added, and such fields will be remembered of course, allowing a middleware-like approach. For example, after having run a query the following fields are added:
Field | Description |
---|---|
res |
Raw node-postgres response |
ret |
Value to be returned by the query |
Instantiating parameters
In general, you have no reason to create such objects manually, as it is automatically done by the query performer routine. However, to offer a deeper understanding, some examples are given below:
// Construct from an entityconst params = conf: Entitydb ent: someEntity ;console; // someEntityconsole; // Name of the primary key columnconsole; // Value for the primary keyconsole; // All the column namesconsole; // All the column values const params2 = conf: Entitydb key: 'id' kval: 123 ;console; // 'id'console; // 123console; // CAVEAT: this is undefined!
Every built-in field specified falsy is deduced, except for conf
and ent
;
leaving such fields blank will not raise an error, however it could lead to
throwing exceptions or undefined behaviour when a hook tries to reference them.
Query hooks
You may want to do some operations (e.g. to check if an entity is valid) before
or after performing queries: this can be done by passing to the Perseest.Config
instance some functions, which we call hooks, with the addHook
method.
A hook can be bound to any specific operation (e.g. save), specifying if it is to be executed before or after, and the adding order is preserved. A hook can also abort a query execution by throwing an error.
Moreover, hooks can return promises or be async functions: this can be very useful if you need to populate fields of your entities with records stored in other tables or databases, as well as to perform other asynchronous task, such as hashing passwords if they have been changed, logging operations without inserting boilerplate code etc.
Let's take again our user example:
Class ... // Hook which validates a user before saving it to the database// Hook parameters are passed in an object (which we are deconstructing) { if !entisValid throw 'User is not valid - Cannot save';} // Add the hookUserdb; // The code below will catch and print an errortry const user = 'b@d N0me' 'bademail' 'notavalidpasswordhash'; user; catch err console;
The query performer routine
When a query is performed (e.g. you call user.delete()
), the following
things happen:
- Before-hooks are executed in order
- A parameterized query is generated by the
generate
query method - The database is called with
pg.Pool.query
- The candidate return value is obtained passing the result to the
transform
method, or to the defaultrow2Entity
if not present - After-hooks are executed in order
- The candidate value (maybe modified by some after-hook) is returned
For a default query, if some column names are given, a check is done to make
sure that they are within the ones given in the Perseest.Config
object.
License
MIT License
Copyright (c) 2020 Paolo Lucchesi
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.