UNPKG

feathers-cassandra

Version:

Feathers service adapter for Cassandra DB based on Express-Cassandra ORM and CassanKnex query builder

653 lines (534 loc) 21.2 kB
# feathers-cassandra [![Build Status](https://travis-ci.org/feathersjs-ecosystem/feathers-cassandra.svg?branch=master)](https://travis-ci.org/feathersjs-ecosystem/feathers-cassandra) [![Coverage Status](https://coveralls.io/repos/github/feathersjs-ecosystem/feathers-cassandra/badge.svg?branch=master)](https://coveralls.io/github/feathersjs-ecosystem/feathers-cassandra?branch=master) [![js-semistandard-style](https://img.shields.io/badge/code%20style-semistandard-brightgreen.svg?style=flat-square)](https://github.com/standard/semistandard) [![Dependency Status](https://img.shields.io/david/feathersjs-ecosystem/feathers-cassandra.svg)](https://david-dm.org/feathersjs-ecosystem/feathers-cassandra) [![npm](https://img.shields.io/npm/v/feathers-cassandra.svg?maxAge=3600)](https://www.npmjs.com/package/feathers-cassandra) [![Greenkeeper badge](https://badges.greenkeeper.io/feathersjs-ecosystem/feathers-cassandra.svg)](https://greenkeeper.io/) [Feathers](https://feathersjs.com/) service adapter for Cassandra DB based on [Express-Cassandra](https://express-cassandra.readthedocs.io) ORM and [CassanKnex](https://github.com/azuqua/cassanknex) query builder ## Installation ```bash npm install --save feathers-cassandra npm install --save express-cassandra npm install --save cassanknex ``` ### [Feathers CLI](https://github.com/feathersjs/cli) Use `feathers generate service` command to generate a new `Cassandra` service. ## Documentation Please refer to the [Feathers database adapter documentation](https://docs.feathersjs.com/api/databases/adapters.html) for more details or directly at: - [Querying](https://docs.feathersjs.com/api/databases/querying.html) - The common adapter querying mechanism - [Extending](https://docs.feathersjs.com/api/databases/common.html#extending-adapters) - How to extend a database adapter Refer to the official [Express-Cassanndra documention](https://express-cassandra.readthedocs.io). It works like the [Knex service](https://github.com/feathersjs/feathers-knex) adapter by using [CassanKnex](https://github.com/azuqua/cassanknex), except it has all the benefits of the Express-Cassandra ORM. ### Service Options - `model` (**required**) - The Express-Cassandra model definition - `id` (*optional*, default: `'id'`) - The name of the id field property. Use array of strings for composite primary keys - `events` (*optional*) - A list of [custom service events](https://docs.feathersjs.com/api/events.html#custom-events) sent by this service - `paginate` (*optional*) - A [pagination object](https://docs.feathersjs.com/api/databases/common.html#pagination) containing a `default` and `max` page size - `multi` (*optional*) - Allow `create` with arrays and `update` and `remove` with `id` `null` to change multiple items. Can be `true` for all methods or an array of allowed methods (e.g. `[ 'remove', 'create' ]`) - `whitelist` (*optional*) - A list of additional query operators to allow (e.g. `[ '$token', '$allowFiltering' ]`) ### Default Query Operators Starting at version 2.0.0 `feathers-cassandra` converts queries securely. If you want to support additional Cassandra operators, the `whitelist` service option can contain an array of additional allowed operators. By default, supported operators are: ``` $eq $ne $gte $gt $lte $lt $in ``` ### Supported Operators ##### Query Operators | Operator | Native Operator | Description | Example | |:---: | :---: | --- | --- | | `$ne` | `!=` | Applicable for IF conditions only | `id: { $ne: 1 }` | | `$isnt` | `IS NOT` | Applicable for materialized view filters only | `id: { $isnt: 1 }` | | `$gt` | `>` | Greater than | `id: { $ne: 1 }` | | `$lt` | `<` | Lower than | `id: { $lt: 1 }` | | `$gte` | `>=` | Greater than or equal | `id: { $gte: 1 }` | | `$lte` | `<=` | Lower than or equal | `id: { $lte: 1 }` | | `$in` | `IN` | Equal to item in list | `id: { $in: [1, 2] }` | | `$like` | `LIKE` | Applicable for SASI indexes only | `text: { $like: '%abc%' }` | | `$sort` | `ORDER BY` | Sort results | ASC: `$sort: { id: 1 }` DESC: `$sort: { id: -1 }` | | `$limit` | `LIMIT` | Sets the maximum number of rows that the query returns | `$limit: 2` | | `$select` | `SELECT` | Sets fields to return. you can also select a field with applied Cassandra function: `writetime`, `ttl`, `dateOf`, `unixTimestampOf`, `toDate`, `toTimestamp` & `toUnixTimestamp` | `$select: ['id', 'name', 'writetime(name)', 'dateOf(name)']` | ##### Cassandra Query Operators | Operator | Native Operator | Description | Example | |:---: | :---: | --- | --- | | `$token` | `TOKEN` | Token query on primary keys. can be used for pagination | Single key: `$token: { id: { $gt: 1 } }` Multiple keys: `$token: { $keys: ['id', 'time'], $condition: { $gt: [1, 2] } }` | | `$minTimeuuid` | `minTimeuuid` | Query on `timeuuid` column given a time component. [read more](https://cassandra.apache.org/doc/latest/cql/functions.html#mintimeuuid-and-maxtimeuuid) | `$minTimeuuid: { timeuuid: { $lt: '2013-02-02 10:00+0000' } }` | | `$maxTimeuuid` | `maxTimeuuid` | Query on `timeuuid` column given a time component. [read more](https://cassandra.apache.org/doc/latest/cql/functions.html#mintimeuuid-and-maxtimeuuid) | `$maxTimeuuid: { timeuuid: { $gt: '2013-01-01 00:05+0000' } }` | | `$contains` | `CONTAINS` | Search in indexed list, set or map | `colors: { $contains: 'blue' }` | | `$containsKey` | `CONTAINS KEY` | Search in indexed map | `colors: { $containsKey: 'dark' }` | | `$if` | `IF` | Condition that must return TRUE for the update to succeed. Will be used automatically when an update, patch or remove request query by id with additional query conditions | `$if: { name: 'John' }` | | `$ifExists` | `IF EXISTS` | Make the UPDATE fail when rows don't match the WHERE conditions | `$ifExists: true` | | `$ifNotExists` | `IF NOT EXISTS` | Inserts a new row of data if no rows match the PRIMARY KEY values | `$ifNotExists: true` | | `$allowFiltering` | `ALLOW FILTERING` | Provides the capability to query the clustering columns using any condition | `$allowFiltering: true` | | `$limitPerPartition` | `PER PARTITION LIMIT` | Sets the maximum number of rows that the query returns from each partition | `$limitPerPartition: 1` | | `$ttl` | `USING TTL` | Sets a time in seconds for data in a column to expire. use in create, update & patch requests | `$ttl: 60` | | `$timestamp` | `USING TIMESTAMP` | Sets a timestamp for data in a column to expire. use in create, update & patch requests | `$timestamp: 1537017312928000` | ##### Special Query Operators | Operator | Native Operator | Description | Example | |:---: | :---: | --- | --- | | `$noSelect` | | Skips SELECT queries in create, update, patch & remove requests. Response data will be based on the input data | `$noSelect: true` | | `$batch` | | Batch create queries. Response data will be based on the input data | `$batch: true` | | `$filters` | | Sets Model's CassanKnex filters to run on a get or find request | `$filters: ['completed', 'recent']` | ##### Cassandra Data Operators | Operator | Native Operator | Description | Example | |:---: | :---: | --- | --- | | `$add` | `+` | Adds to a list, set or map | List/Set: `colors: { $add: ['blue', 'red'] }` Map: `colors: { $add: { dark: 'blue', bright: 'red' } }` | | `$remove` | `-` | Removes from a list, set or map | List/Set: `colors: { $remove: ['blue', 'red'] }` Map: `colors: { $remove: ['dark', 'bright'] }` | | `$increment` | `+` | Increments a counter | `days: { $increment: 2 }` | | `$decrement` | `-` | Decrements a counter | `days: { $decrement: 2 }` | ### Passing Cassandra [queryOptions](https://docs.datastax.com/en/developer/nodejs-driver/4.5/api/type.QueryOptions/) Set `params.queryOptions` to override options per query, like [setting a different consistency level for a single query](https://docs.datastax.com/en/developer/nodejs-driver/4.5/getting-started/#setting-the-consistency-level). ### Materialized Views A materialized view will be automatically queried against when a query contains only that view's keys. ### Model Hooks Works like [Express-Cassandra Hook Functions](https://express-cassandra.readthedocs.io/en/stable/management/#hook-functions), but arguments will contain Feathers-Cassandra equivalent objects - data, query, query operators as options & id. ### Model CassanKnex Filters Filter functions that call CassanKnex methods on the query builder object before execution. Filter functions runs in get & find requests when specified in the `query.$filters` array. ### Cassandra Set Cassandra init options as defined in [Cassandra](https://docs.datastax.com/en/developer/nodejs-driver/4.5/api/type.ClientOptions/) & [Express-Cassandra](https://express-cassandra.readthedocs.io/en/latest/usage/#explanations-for-the-options-used-to-initialize): config/defaults.json ```json { "cassandra": { "clientOptions": { "contactPoints": [ "127.0.0.1" ], "protocolOptions": { "port": 9042 }, "keyspace": "test", "queryOptions": { "consistency": 1 } }, "ormOptions": { "defaultReplicationStrategy": { "class": "SimpleStrategy", "replication_factor": 1 }, "migration": "alter", "createKeyspace": true } } } ``` cassandra.js ```js const ExpressCassandra = require('express-cassandra') const FeathersCassandra = require('feathers-cassandra') module.exports = function (app) { const connectionInfo = app.get('cassandra') const models = ExpressCassandra.createClient(connectionInfo) const cassandraClient = models.orm.get_system_client() app.set('models', models) cassandraClient.connect(err => { if (err) throw err const cassanknex = require('cassanknex')({ connection: cassandraClient }) FeathersCassandra.cassanknex(cassanknex) cassanknex.on('ready', err => { if (err) throw err }) }) } ``` ### Models Define [Express-Cassandra Models](https://express-cassandra.readthedocs.io/en/latest/schema/) for your tables: todos.model.js ```js module.exports = function (app) { const models = app.get('models') const Todo = models.loadSchema('Todo', { table_name: 'todo', fields: { id: 'int', text: { type: 'text', rule: { required: true, validators: [ { validator: function (value) { return value !== 'forbidden' }, message: '`forbidden` is a reserved word' } ] } }, complete: 'boolean', teams: { type: 'map', typeDef: '<text, text>' }, games: { type: 'list', typeDef: '<text>' }, winners: { type: 'set', typeDef: '<text>' } }, key: ['id'], custom_indexes: [ { on: 'text', using: 'org.apache.cassandra.index.sasi.SASIIndex', options: {} }, { on: 'complete', using: 'org.apache.cassandra.index.sasi.SASIIndex', options: {} } ], options: { // timestamps: true timestamps: { createdAt: 'created_at', // defaults to createdAt updatedAt: 'updated_at' // defaults to updatedAt }, // versions: true versions: { key: '_version' // defaults to __v } }, filters: { completed: builder => { builder.where('complete', '=', true) } }, before_save: function (instance, options) { instance.complete = false return true }, after_save: function (instance, options) { return true }, before_update: function (queryObject, updateValues, options, id) { updateValues.complete = true return true }, after_update: function (queryObject, updateValues, options, id) { return true }, before_delete: function (queryObject, options, id) { return true }, after_delete: function (queryObject, options, id) { return true } }, function (err) { if (err) throw err }) Todo.syncDB(function (err) { if (err) throw err }) return Todo } ``` When defining a service, you must provide the model: ```js app.use('/todos', service({ model: Todo }) ``` ### Service todos.service.js ```js const createService = require('feathers-cassandra') const createModel = require('./todos.model') module.exports = function (app) { const Model = createModel(app) const options = { model: Model, paginate: { default: 2, max: 4 }, whitelist: ['$allowFiltering', '$filters', '$ttl', '$if'] } app.use('/todos', createService(options)) } ``` ### Composite primary keys Composite primary keys can be passed as the `id` argument using the following methods: * String with values separated by the `idSeparator` property (order matter, recommended for REST) * JSON array (order matter, recommended for internal service calls) * JSON object (more readable, recommended for internal service calls) When calling a service method with the `id` argument, all primary keys are required to be passed. #### Options * **`idSeparator`** - (optional) separator char to separate Composite primary keys in the `id` argument of get/patch/update/remove external service calls. Defaults to `','`. ```js app.use('/user-todos', service({ idSeparator: ',' }) app.service('/user-todos').get('1,2') app.service('/user-todos').get([1, 2]) app.service('/user-todos').get({ userId: 1, todoId: 2 }) ``` * **`materializedViews`** - (optional) array of materialized views to use when queries contains the same set of columns that constructs their compound PK. ```js app.use('/players', service({ materializedViews: [ { view: 'top_season_players', keys: [ 'season', 'score' ] } ] }) ``` ## Complete Example Here's a complete example of a Feathers server with a `todos` Feathers-Cassandra service: ```js const feathers = require('@feathersjs/feathers') const express = require('@feathersjs/express') const rest = require('@feathersjs/express/rest') const errorHandler = require('@feathersjs/express/errors') const bodyParser = require('body-parser') const ExpressCassandra = require('express-cassandra') const FeathersCassandra = require('feathers-cassandra') // Initialize Express-Cassandra const models = ExpressCassandra.createClient({ clientOptions: { contactPoints: ['127.0.0.1'], localDataCenter: 'datacenter1', protocolOptions: { port: 9042 }, keyspace: 'test', queryOptions: { consistency: ExpressCassandra.consistencies.one } }, ormOptions: { defaultReplicationStrategy: { class: 'SimpleStrategy', replication_factor: 1 }, migration: 'alter', createKeyspace: true } }) // Get Cassandra client const cassandraClient = models.orm.get_system_client() // Connect to Cassandra cassandraClient.connect(err => { if (err) throw err // Initialize CassanKnex with the current Cassandra connection const cassanknex = require('cassanknex')({ connection: cassandraClient }) // Bind CassanKnex FeathersCassandra.cassanknex(cassanknex) cassanknex.on('ready', err => { if (err) throw err }) }) // Create a feathers instance. const app = express(feathers()) // Enable REST services .configure(rest()) // Turn on JSON parser for REST services .use(bodyParser.json()) // Turn on URL-encoded parser for REST services .use(bodyParser.urlencoded({ extended: true })) app.set('models', models) // Create an Express-Cassandra Model const Todo = models.loadSchema('Todo', { table_name: 'todo', fields: { id: 'int', text: { type: 'text', rule: { required: true, validators: [ { validator: function (value) { return value !== 'forbidden' }, message: '`forbidden` is a reserved word' } ] } }, complete: 'boolean', teams: { type: 'map', typeDef: '<text, text>' }, games: { type: 'list', typeDef: '<text>' }, winners: { type: 'set', typeDef: '<text>' } }, key: ['id'], custom_indexes: [ { on: 'text', using: 'org.apache.cassandra.index.sasi.SASIIndex', options: {} }, { on: 'complete', using: 'org.apache.cassandra.index.sasi.SASIIndex', options: {} } ], options: { timestamps: { createdAt: 'created_at', // defaults to createdAt updatedAt: 'updated_at' // defaults to updatedAt }, versions: { key: '_version' // defaults to __v } }, filters: { completed: builder => { builder.where('complete', '=', true) // CassanKnex filter } }, before_save: function (instance, options) { instance.complete = false return true }, after_save: function (instance, options) { return true }, before_update: function (queryObject, updateValues, options, id) { updateValues.complete = true return true }, after_update: function (queryObject, updateValues, options, id) { return true }, before_delete: function (queryObject, options, id) { return true }, after_delete: function (queryObject, options, id) { return true } }, function (err) { if (err) throw err }) Todo.syncDB(function (err) { if (err) throw err }) // Create Cassandra Feathers service with a default page size of 2 items // and a maximum size of 4 app.use('/todos', FeathersCassandra({ model: Todo, paginate: { default: 2, max: 4 } })) // Handle Errors app.use(errorHandler()) // Start the server module.exports = app.listen(3030) console.log('Feathers Todo FeathersCassandra service running on 127.0.0.1:3030') ``` Run the example with `node app` and go to [localhost:3030/todos](http://localhost:3030/todos). You should see an empty array. That's because you don't have any Todos yet, but you now have full CRUD for your new todos service! ## DB migrations [Knex Migration CLI](http://knexjs.org/#Migrations) can also be used to manage DB migrations and to [seed](http://knexjs.org/#Seeds) a table with mock data: Change `config.cassandra.ormOptions.migration` to `'safe'`. Create `cassanknex.js` file: ```js const ExpressCassandra = require('express-cassandra'); const config = require('config'); let cassanknex = null; const getCassanknex = async () => { return new Promise((resolve, reject) => { if (cassanknex) { resolve(cassanknex); return; } const connectionInfo = config.cassandra; if (connectionInfo.clientOptions.queryOptions.consistency) connectionInfo.clientOptions.queryOptions.consistency = ExpressCassandra.consistencies[connectionInfo.clientOptions.queryOptions.consistency]; connectionInfo.connection = connectionInfo.clientOptions; try { cassanknex = require('cassanknex')(connectionInfo); cassanknex.on('ready', function (err) { if (err) { reject(err); return; } resolve(cassanknex); }); } catch (err) { reject(err); } }); }; module.exports = { getCassanknex, }; ``` Use it inside a Knex migration file: ```js const { getCassanknex } = require('../cassanknex'); exports.up = () => { return new Promise(async (resolve, reject) => { const cassanknex = await getCassanknex(); cassanknex('example').createColumnFamilyIfNotExists('table') .uuid('id') .text('data') .primary('id') .exec((err, result) => { if (err) { reject(err); return; } resolve(); }); }); }; exports.down = () => { return new Promise(async (resolve, reject) => { const cassanknex = await getCassanknex(); cassanknex('example').dropColumnFamilyIfExists('table') .exec((err, result) => { if (err) { reject(err); return; } resolve(); }); }); }; ``` ## Error handling As of version 3.4.0, `feathers-cassandra` only throws [Feathers Errors](https://docs.feathersjs.com/api/errors.html) with the message. On the server, the original error can be retrieved through a secure symbol via `error[require('feathers-cassandra').ERROR]`. ```js const { ERROR } = require('feathers-cassandra'); try { await cassandraService.doSomething(); } catch (error) { // error is a FeathersError with just the message // Safely retrieve the original error const originalError = error[ERROR]; } ``` ## Migrating to `feathers-cassandra` v2 `feathers-cassandra` 2.0.0 comes with important security and usability updates. > __Important:__ For general migration information to the new database adapter functionality see [docs.feathersjs.com/guides/migrating.html#database-adapters](https://docs.feathersjs.com/guides/migrating.html#database-adapters). The following breaking changes have been introduced: - All methods allow additional query parameters - Multiple updates are disabled by default (see the `multi` option) - Cassandra related operators are disabled by default (see the `whitelist` option) ## License Copyright © 2020 Licensed under the [MIT license](LICENSE).