项目作者: jcondor98

项目描述 :
Persistent entities using node-postgres and ES6 classes and mixins
高级语言: JavaScript
项目地址: git://github.com/jcondor98/perseest.git
创建时间: 2020-05-13T20:41:55Z
项目社区:https://github.com/jcondor98/perseest

开源协议:MIT License

下载


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:

  1. 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:

  1. const Perseest = require('perseest');
  2. // Using Perseest.Class
  3. class User extends Perseest.Class {
  4. constructor(name, email, hash) {
  5. super(); // Perseest constructors do not need any argument
  6. this.name = name;
  7. this.email = email;
  8. this.hash = hash;
  9. }
  10. ...
  11. // Use as Perseest.Config(table_name, primary_key, columns)
  12. // Columns are specified as an enumerable object in the form:
  13. // { column_name: { attribute1: <some-value>, ... }, ... }
  14. static db = new Perseest.Config('UserAccount', 'id', {
  15. id: { serial: true },
  16. name: { id: true },
  17. email: { id: true },
  18. hash: null // {} is the same
  19. });
  20. // Moreover, columns can be specified as a [name,attributes] array, and when
  21. // there are no options to specify, you can just use a string.
  22. // This can be handy with a lot of columns without attributes
  23. static db = new PerseestConfig('UserAccount', 'id', [
  24. 'a', 'lot', 'of', 'user', 'fields', 'but', 'just', 'one', 'with',
  25. ['attributes', { id: true }]
  26. ]);
  27. }
  28. // Using Perseest.Mixin
  29. class VolatileUser {
  30. constructor(name, email, hash) {
  31. this.name = name;
  32. this.email = email;
  33. this.hash = hash;
  34. }
  35. ...
  36. }
  37. class User extends Perseest.Mixin(VolatileUser) {
  38. static db = new Perseest.Config('UserAccount', 'id', {
  39. id: { serial: true },
  40. name: { id: true },
  41. email: { id: true },
  42. hash: null
  43. });
  44. }

Column attributes define special behaviours for particular columns:

  • id specifies that the column can be used as a univocal id
  • serial 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 to update())

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:

  1. const user = new User(/* ... */);
  2. // Set up the database
  3. User.db.setup('postgres://user:pass@www.some.db/table');
  4. // Save the user (falls back to update() if user exists)
  5. user.save();
  6. // Update user columns
  7. user.update(); // All the columns...
  8. user.update('email', 'hash'); // ...or just a few
  9. // Fetch a user
  10. const fetched = User.fetch('email', 'some@ema.il');
  11. if (fetched === null)
  12. console.log('User not found');
  13. else
  14. user.doALotOfBeautifulThings();
  15. // Delete a user
  16. user.delete(); // By instance...
  17. User.delete('name', 'homer'); // ...or by id
  18. // Fetch many users
  19. const many = User.fetchMany({ role: 'admin' }); // Require role equals 'admin'
  20. const all = User.fetchMany(); // Fetch all the users
  21. // Delete many users
  22. User.deleteMany({ dangerous: true }); // 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):

  1. // First, we define our transform function
  2. function transformer({ conf, res }) {
  3. let retObj = {
  4. entities: res.rows.map(e => conf.row2Entity(e)),
  5. repetitive: []
  6. };
  7. if (res.rows.length === 0) return retObj;
  8. for (const f of res.fields) {
  9. const example = res.rows[0][f.name];
  10. for (const x of res.rows)
  11. if (x[f.name] !== example) break;
  12. retObj.push(f.name);
  13. }
  14. return retObj;
  15. }
  16. // Let's add a query type
  17. SomePerseestentClass.db.types.set('repetitiveSpotter', transformer);
  18. // Now we can create queries using that type (after I will explain how queries
  19. // can be defined)
  20. SomePerseestentClass.db.queries.create({
  21. name: 'someQuery',
  22. type: 'repetitiveSpotter',
  23. generate: params => { ... }
  24. });

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:

  1. // Let's consider a DB table containing some log messages
  2. class LogMessage extends Perseest.Class {
  3. static db = new Perseest.Config('Messages', 'id', ['content', 'severity'])
  4. }
  5. // e.g.: We want to know if there is any error present in the table
  6. // (useless, trivial and stupid example, but will do)
  7. try {
  8. LogMessage.db.queries.create({
  9. name: 'anyError',
  10. type: 'boolean',
  11. generator: ({ conf }) => ({
  12. text: `SELECT * FROM ${conf.table} WHERE severity = "error" LIMIT 1`,
  13. values: []
  14. })
  15. })
  16. } catch (err) {
  17. throw err
  18. }
  19. // We can now call the defined query
  20. LogMessage.db.queries.run('anyError', { conf: LogMessage.db })
  21. .then(presence => console.log('Got ' + presence ? '' : 'no ' + 'errors')
  22. .catch(console.error)
  23. // The above call can easily become a mess with a slightly more complicated
  24. // query. It can be handy to wrap our query calls in instance methods
  25. LogMessage.anyError = async function anyError() {
  26. try {
  27. return await LogMessage.db.queries.run('anyError', { conf: LogMessage.db })
  28. } catch (err) {
  29. throw err;
  30. }
  31. }
  32. LogMessage.anyError()
  33. .then(presence => console.log('Got ' + presence ? '' : 'no ' + 'errors')
  34. .catch(console.error)

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:

  1. // Construct from an entity
  2. const params = new Perseest.Parameters({
  3. conf: Entity.db ent: someEntity });
  4. console.log(params.ent); // someEntity
  5. console.log(params.key); // Name of the primary key column
  6. console.log(params.kval); // Value for the primary key
  7. console.log(params.columns); // All the column names
  8. console.log(params.values); // All the column values
  9. const params2 = new Perseest.Parameters({
  10. conf: Entity.db, key: 'id', kval: 123 });
  11. console.log(params.key); // 'id'
  12. console.log(params.kval); // 123
  13. console.log(params.ent); // 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:

  1. class User extends Perseest.Class { ... }
  2. // Hook which validates a user before saving it to the database
  3. // Hook parameters are passed in an object (which we are deconstructing)
  4. function validateBeforeSave({ ent }) {
  5. if (!ent.isValid)
  6. throw new Error('User is not valid - Cannot save');
  7. }
  8. // Add the hook
  9. User.db.addHook('before', 'save', validateBeforeSave);
  10. // The code below will catch and print an error
  11. try {
  12. const user = new User('b@d N0me', 'bademail', 'notavalidpasswordhash');
  13. user.save();
  14. } catch (err) {
  15. console.error('ERRORE!!!!!!', err);
  16. }

The query performer routine

When a query is performed (e.g. you call user.delete()), the following
things happen:

  1. Before-hooks are executed in order
  2. A parameterized query is generated by the generate query method
  3. The database is called with pg.Pool.query
  4. The candidate return value is obtained passing the result to the transform
    method, or to the default row2Entity if not present
  5. After-hooks are executed in order
  6. 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.