Skip to content

Commit

Permalink
Merge pull request #2 from dsfields/feature/nil-literal
Browse files Browse the repository at this point in the history
Feature/nil literal
  • Loading branch information
dsfields committed Jun 30, 2017
2 parents f2b9aa9 + 6ec2997 commit 4f88508
Show file tree
Hide file tree
Showing 18 changed files with 925 additions and 193 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@

## 1.0

### 1.1.0

* Adding support for the `nil` literal.

### 1.0.0
* Initial release.
51 changes: 46 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

Representing filter expressions across application layers is a pretty common problem. Say we have a REST endpoint that accepts a filter, which is then deserialized, passed to your domain logic for processing, and then passed into your data access layer for querying information. There are a couple of issues that come out of this scenario. How is the filter expression formatted when it's passed in via an HTTP request? How do we pass this expression to our domain logic and data access layers without leaking implementation details? The `spleen` module seeks to solve these issues.

There are a number of competing methods for solving this issue. Most notably GraphQL and OData. However, these tools are not suitable for all use cases. The `spleen` module is ideally suited for RESTful and intent-based API designs, and requires minimal effort to implement.

__Contents__

* [Usage](#usage)
Expand All @@ -22,6 +20,7 @@ __Contents__
+ [Class: `Range`](#class-range)
+ [Class: `Target`](#class-target)
* [Conversions](#conversions)
* [Motivation](#motivation)

## Usage

Expand All @@ -46,7 +45,7 @@ Or define filter graphs directly (which is more efficient from a runtime perspec
```js
const spleen = require('spleen');

const Clause = filtering.Clause;
const Clause = spleen.Clause;
const Filter = spleen.Filter;

const filter = Filter
Expand Down Expand Up @@ -92,7 +91,7 @@ Expression strings use infix notation to maximize read and writability by humans
[<conjunctive/> <clause/>] ...
```

* `<group> ... </group>` _(optional)_ A logical grouping of filter clauses. This is useful when conjunctive normal form clauses are used with the individual evaluation of a series of disjunctive normal form clauses, and visa versa.
* `<group> ... </group>` _(optional)_ A logical grouping of filter clauses.

* `<clause/>`: _(required)_ a statement that describes a filter condition. Each statement must follow a specific form:

Expand Down Expand Up @@ -150,6 +149,8 @@ The following is a list of all possible values for the various types of terms us

+ `true` or `false`: a Boolean value.

+ `nil`: a null literal value.

* `<verb:compare/>`:

+ `eq`: equal to.
Expand Down Expand Up @@ -228,6 +229,12 @@ Field `bar` of field `foo` is not equal to string literal `"baz"`, and field `qu
/foo/bar neq "baz" and /qux gte 42
```

Field `bar` of field `foo` is equal to `nil` (`null`).

```
/foo/bar eq nil
```

The conditions field `bar` of field `foo` is not equal to string literal `"baz"` and field `qux` is greater than or equal to 42 must be true, or the field `quux` must start with `"Hello"`.

```
Expand Down Expand Up @@ -319,7 +326,7 @@ The primary interface exposes all of the classes needed to build `spleen` filter

+ `spleen.Like`: gets a reference to the [`Like`](#class-like) class.

+ `spleen.parse(value)` parses a string into an instance of `Filter`. This method takes a single string argument which represents the filter. If the filter is invalid, a `ParseError` is thrown.
+ `spleen.parse(value)` parses a string into a parse result object. This method takes a single string argument which represents the filter. If the filter is invalid, the return object's success property will be false and the error property will contain the error message. If the filter is valid, the return object's success property will be set to true and the value property will be set to an instance of `Filter`.

+ `spleen.Range`: gets a reference to the [`Range`](#class-range) class.

Expand Down Expand Up @@ -586,3 +593,37 @@ Represents a reference to a field on an object being filtered.
One of the goals of `spleen` is to provide a high-level abstraction for filter expressions. The idea is to provide a DSL that can be consistently used across application layers without leaking implementation details. Each layer in the application is then responsible for consuming a `spleen` filter expression in its own way.

In the case of a data access layer, this typically means converting a `Filter` instance into some flavor of SQL. For now, there is a single plugin available for accomplishing this end: [spleen-n1ql](https://www.npmjs.com/package/spleen-n1ql) (for now).

## Motivation

Representing complex filter expressions is a fairly common problem for API developers. There are a variety of methods commonly used by teams, and they all have their pros and cons...

* Use the query string to pass in filter criteria.<br />
__Pros:__ Very easy to implement. Universally understood.<br />
__Cons:__ Query strings have no native way of specifying comparison operators. This makes it difficult to make your APIs idiomatic.

* Expose the underlying query language used by your database. Drawbacks:<br />
__Pros:__ Can provide a lot of power. Little to no effort to implement.<br />
__Cons:__ It leaks internal implementation details. It's difficult to secure.

* Build a custom filter dialect and parser.<br />
__Pros:__ Greater control over the software.<br />
__Cons:__ Teams often make these tools domain-specific. They are complex and time-consuming to build. Closed-source solutions do not benefit from a larger community of people and companies testing and contributing to the project.

* Use frameworks for querying languages such as GraphQL and OData.<br />
__Pros:__ Very robust. Support for full ad-hoc querying.<br />
__Cons:__ Represents a broader system design. May not be practical for use in existing systems built on intent-based design (like REST). Built around opinionated frameworks that can be complicated to implement.

The `spleen` module addresses these challenges wtih the following goals in minds:

* No strong opinions. The `spleen` module is a library, and does not insist upon any broader design patterns.

* Can be implemented with minimal effort.

* Supports complex filters with support for a variety of comparison operators, functions, and conjunctions.

* Provides an abstraction around the issue of filtering data.

* Domain agnostic.

* Allows API endpoints to utilize a single query parameter for filtering. This makes your APIs more idiomatic, and your code simpler.
24 changes: 17 additions & 7 deletions lib/clause.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ const msg = {
};


const empty = { isEmpty: true };


class Clause {

constructor() {
this.subject = null;
this.operator = null;
this.object = null;
this.subject = empty;
this.operator = empty;
this.object = empty;

this.eq = this._eq;
this.neq = this._neq;
Expand Down Expand Up @@ -56,7 +59,11 @@ class Clause {

static _assertLiteral(value) {
const tval = typeof value;
if (tval !== 'string' && tval !== 'number' && tval !== 'boolean')
if (tval !== 'string'
&& tval !== 'number'
&& tval !== 'boolean'
&& value !== null
)
throw new TypeError(msg.literal);
}

Expand Down Expand Up @@ -294,9 +301,9 @@ class Clause {

isValid() {
return (
this.subject !== null
&& this.operator !== null
&& this.object !== null
this.subject !== empty
&& this.operator !== empty
&& this.object !== empty
);
}

Expand Down Expand Up @@ -341,6 +348,9 @@ class Clause {


static _stringify(value, urlEncode) {
if (value === null)
return 'nil';

if (typeof value === 'number' || typeof value === 'boolean')
return value.toString();

Expand Down
18 changes: 13 additions & 5 deletions lib/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ class Parser {


_literal(clause) {
if (!this._accept(Token.number) && !this._accept(Token.string))
this._expect(Token.boolean);
if (!this._accept(Token.number)
&& !this._accept(Token.string)
&& !this._accept(Token.boolean)
)
this._expect(Token.nil);

const value = this.tokenizer.current.value;
this.tokenizer.next();
Expand Down Expand Up @@ -67,8 +70,11 @@ class Parser {
this.tokenizer.next();
}

if (!this._accept(Token.string) && !this._accept(Token.number))
this._expect(Token.boolean);
if (!this._accept(Token.string)
&& !this._accept(Token.number)
&& !this._accept(Token.boolean)
)
this._expect(Token.nil);

value.push(this.tokenizer.current.value);
}
Expand All @@ -81,7 +87,9 @@ class Parser {


_rangeLiteral() {
if (!this._accept(Token.string)) this._expect(Token.number);
if (!this._accept(Token.string) && !this._accept(Token.number))
this._expect(Token.nil);

return this.tokenizer.current.value;
}

Expand Down
10 changes: 4 additions & 6 deletions lib/range.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
'use strict';

const elv = require('elv');


const assert = function (val, name) {
const type = typeof val;

if (type !== 'string' && type !== 'number')
throw new TypeError(`Argument "${name}" must be a string or number`);
if (type !== 'string' && type !== 'number' && val !== null)
throw new TypeError(`Argument "${name}" must be a string, number, or null`);
};


Expand All @@ -28,13 +25,14 @@ class Range {


between(value) {
if (!elv(value)) return false;
if (typeof value === 'undefined') return false;
assert(value, 'value');
return value >= this.lower && value <= this.upper;
}


static _stringify(value, urlEncode) {
if (value === null) return 'nil';
if (typeof value === 'number') return value.toString();
const val = (urlEncode) ? escape(value) : value;
return `"${val}"`;
Expand Down
1 change: 1 addition & 0 deletions lib/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ module.exports = {
listDelimiter: 21,
openArray: 22,
closeArray: 23,
nil: 24,
};
6 changes: 6 additions & 0 deletions lib/tokenizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ class Tokenizer {
value: false,
};

case 'nil':
return {
type: Token.nil,
value: null,
};

case 'and':
return {
type: Token.and,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "spleen",
"longName": "spleen",
"description": "Filter expression parsing, formatting, and abstractions for Node.js.",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"elv": "^1.0.0"
},
Expand Down

0 comments on commit 4f88508

Please sign in to comment.