diff --git a/package.json b/package.json index f6a962b1..221aeae8 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "cypher-query-builder", - "version": "6.0.4", + "version": "6.1.0", "description": "An intuitive, easy to use query builder for Neo4j and Cypher", "author": "James Ferguson", + "contributors": ["Bastian Charlet"], "license": "MIT", - "repository": "github:jamesfer/cypher-query-builder", + "repository": "github:bastianowicz/cypher-query-builder", "main": "dist/cjs5.js", "module": "dist/esm5.js", "es2015": "dist/esm2015.js", @@ -28,6 +29,7 @@ "build:rollup": "scripts/build rollup", "docs": "scripts/docs", "lint": "scripts/lint", + "prepare": "yarn build", "report": "scripts/report", "test": "scripts/test-integration", "test:unit": "scripts/test", @@ -89,7 +91,7 @@ }, "dependencies": { "@types/lodash": "^4.14.136", - "@types/node": "^12.6.1", + "@types/node": "^12.19.9", "lodash": "^4.17.15", "neo4j-driver": "^4.1.2", "node-cleanup": "^2.1.2", diff --git a/src/builder.ts b/src/builder.ts index 2d95675b..a093bc18 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -16,12 +16,16 @@ import { Clause } from './clause'; import { RemoveProperties } from './clauses/remove'; import { Union } from './clauses/union'; import { ReturnOptions } from './clauses/return'; +import { StringKeyOf, TypedDictionary, ValueOf } from './types'; +import { Query } from './query'; +import { Selector } from './selector'; +import { ReturnObject, Selectable } from './clauses/returnObject'; /** * @internal */ export interface WrapperClause { - new (clause: Set): Clause; + new(clause: Set): Clause; } /** @@ -168,9 +172,14 @@ export class SetBlock { /** * Root class for all query chains, namely the {@link Connection} and * {@link Query} classes. + * * @internal + * @typeParam {Builder} Q - Type of the builder (Query, Connection, ...) + * @typeParam {Builder} G - GraphModel that is currently processable. Defaults to any + * but can be something more specific like a model of your graph with all its properties */ -export abstract class Builder extends SetBlock { +export abstract class Builder + , G = any> extends SetBlock { protected constructor() { super(c => this.continueChainClause(c)); } @@ -247,41 +256,70 @@ export abstract class Builder extends SetBlock { * query.create([node('people', 'Person', { age: 30 })], { unique: true }); * // CREATE UNIQUE (people:Person { age: 30 }) * ``` + * + * @typeParam N (optional)- New GraphModel G after this call. Defaults to G. + * (@see {@link Builder}) + * @param patterns - Collection of patterns that are compatible to + * @param options - options for CREATE */ - create(patterns: PatternCollection, options?: CreateOptions) { - return this.continueChainClause(new Create(patterns, options)); + create( + patterns: PatternCollection, Partial>>, + options?: CreateOptions, + ): Query { + return this.continueChainClause(new Create(patterns, options)) as any as Query; } /** * Shorthand for `create(patterns, { unique: true })` + * + * @typeParam N (optional)- New GraphModel G after this call. Defaults to G. + * (@see {@link Builder}) + * @param patterns - Collection of patterns that are compatible to */ - createUnique(patterns: PatternCollection) { - return this.create(patterns, { unique: true }); + createUnique( + patterns: PatternCollection, Partial>>, + ): Query { + return this.create(patterns, { unique: true }) as any as Query; } /** * Shorthand for `create(node(name, labels, conditions), options)`. For more details * the arguments see @{link node}. + * + * @typeParam N - (optional) New GraphModel G after this call. (@see {@link Builder}) + * @param name - single name, list of names or dictionary of nodes that must be compatible to + * the given new GraphModel + * @param labels - labels to attach + * @param conditions - properties of node that must be compatible to new GraphModel + * @param options - options for CREATE */ - createNode( - name: Many | Dictionary, + createNode( + name: Many> | Dictionary>, labels?: Many | Dictionary, - conditions?: Dictionary, + conditions?: Partial>, options?: CreateOptions, - ) { - const clause = new Create(new NodePattern(name, labels, conditions), options); - return this.continueChainClause(clause); + ): Query { + const clause = new Create( + new NodePattern, Partial>>(name, labels, conditions), + options, + ); + return this.continueChainClause(clause) as any as Query; } /** * Shorthand for `createNode(name, labels, conditions, { unique: true })` + * + * @typeParam N - (optional) New GraphModel after this call. (@see {@link Builder}) + * @param name - single name, list of names or dictionary of nodes (keys of ) + * @param labels - labels to attach + * @param conditions - properties of node that must be compatible to new GraphModel */ - createUniqueNode( - name: Many | Dictionary, + createUniqueNode( + name: Many> | Dictionary>, labels?: Many | Dictionary, - conditions?: Dictionary, - ) { - return this.createNode(name, labels, conditions, { unique: true }); + conditions?: Partial>, + ): Query { + return this.createNode(name, labels, conditions, { unique: true }) as any as Query; } /** @@ -296,25 +334,29 @@ export abstract class Builder extends SetBlock { * You can set `detach: true` in the options to make it a `DETACH DELETE` * clause. * - * @param {_.Many} terms + * @typeParam T (optional) Keys of Graph model that are deleted + * @param {_.Many} terms (keys of ) * @param {DeleteOptions} options - * @returns {Q} + * @returns {Query} Query with graph model excluding the deleted parts */ - delete(terms: Many, options?: DeleteOptions) { - return this.continueChainClause(new Delete(terms, options)); + delete = StringKeyOf> + (terms: Many, options?: DeleteOptions): Query> { + return this.continueChainClause(new Delete(terms, options)) as any as Query>; } /** * Shorthand for `delete(terms, { detach: true })`. * - * @param {_.Many} terms + * @typeParam T (optional) Keys of Graph model that are deleted + * @param {_.Many} terms (keys of ) * @param {DeleteOptions} options * @returns {Q} */ - detachDelete(terms: Many, options: DeleteOptions = {}) { - return this.continueChainClause(new Delete(terms, assign(options, { + detachDelete = StringKeyOf> + (terms: Many, options: DeleteOptions = {}): Query> { + return this.delete(terms, assign(options, { detach: true, - }))); + })) as any as Query>; } /** @@ -324,7 +366,7 @@ export abstract class Builder extends SetBlock { * @param {string | number} amount * @returns {Q} */ - limit(amount: number) { + limit(amount: number): Q { return this.continueChainClause(new Limit(amount)); } @@ -362,12 +404,19 @@ export abstract class Builder extends SetBlock { * You can also provide `optional: true` in the options to create and * `OPTIONAL MATCH` clause. * + * @typeParam N - (optional) type of GraphModel after this call + * @typeParam C - (optional) Interface for conditions * @param {PatternCollection} patterns List of patterns to be matched. * @param {MatchOptions} options + * * @returns {Q} */ - match(patterns: PatternCollection, options?: MatchOptions) { - return this.continueChainClause(new Match(patterns, options)); + match = ValueOf>( + patterns: PatternCollection, Partial>, + options?: MatchOptions): Query { + return this.continueChainClause( + new Match, Partial>(patterns, options), + ) as any as Query; } /** @@ -377,15 +426,17 @@ export abstract class Builder extends SetBlock { * @param {_.Many | _.Dictionary} name * @param {_.Many | _.Dictionary} labels * @param {_.Dictionary} conditions - * @returns {Q} + * @typeParam N - (optional) type of GraphModel after this call (G -> N). + * @typeParam C - (optional) Interface for conditions + * @returns {Query} */ - matchNode( - name?: Many | Dictionary, + matchNode = ValueOf>( + name?: Many> | Dictionary>, labels?: Many | Dictionary, - conditions?: Dictionary, - ) { - const clause = new Match(new NodePattern(name, labels, conditions)); - return this.continueChainClause(clause); + conditions?: Partial, + ): Query { + const clause = new Match(new NodePattern, Partial>(name, labels, conditions)); + return this.continueChainClause(clause) as any as Query; } /** @@ -393,12 +444,16 @@ export abstract class Builder extends SetBlock { * * @param {PatternCollection} patterns * @param {MatchOptions} options + * @typeParam N - (optional) type of GraphModel after this call (G -> N). + * @typeParam C - (optional) Interface for conditions * @returns {Q} */ - optionalMatch(patterns: PatternCollection, options: MatchOptions = {}) { - return this.continueChainClause(new Match(patterns, assign(options, { + optionalMatch = ValueOf>( + patterns: PatternCollection, Partial>, + options?: MatchOptions): Query { + return this.match(patterns, assign(options, { optional: true, - }))); + })) as any as Query; } /** @@ -416,9 +471,13 @@ export abstract class Builder extends SetBlock { * // MERGE (user:User { id: 1 })-[rel:OwnsProject]->(project:Project { id: 20 }) * // ON MATCH SET rel.updatedAt = timestamp() * ``` + * @param {PatternCollection} patterns + * @typeParam N - (optional) type of GraphModel after this call (G -> N). + * @typeParam C - (optional) Interface for conditions */ - merge(patterns: PatternCollection) { - return this.continueChainClause(new Merge(patterns)); + merge = ValueOf>( + patterns: PatternCollection, Partial>): Query { + return this.continueChainClause(new Merge(patterns)) as any as Query; } /** @@ -476,7 +535,9 @@ export abstract class Builder extends SetBlock { * @param {Direction} dir * @returns {Q} */ - orderBy(fields: string | (string | OrderConstraint)[] | OrderConstraints, dir?: Direction) { + orderBy( + fields: StringKeyOf | (StringKeyOf | OrderConstraint)[] | OrderConstraints, + dir?: Direction) { return this.continueChainClause(new OrderBy(fields, dir)); } @@ -543,8 +604,13 @@ export abstract class Builder extends SetBlock { * * If you only need to remove labels *or* properties, you may find `removeProperties` or * `removeLabels` more convenient. + * + * @typeParam T - (optional) any ot Type of target in GraphModel whose properties shall be removed + * @param {RemoveProperties} properties - properties to remove from target + * properties might be constrained to values of graphModel and keys of target */ - remove(properties: RemoveProperties) { + remove = any> + (properties: RemoveProperties, StringKeyOf>) { return this.continueChainClause(new Remove(properties)); } @@ -556,14 +622,21 @@ export abstract class Builder extends SetBlock { * object is the name of a node and the values are the names of the properties to remove. The * values can be either a single string, or an array of strings. * ```javascript - * query.remove({ + * query.removeProperties({ * customer: ['inactive', 'new'], * coupon: 'available', * }); * // REMOVE customer.inactive, customer.new, coupon.available * ``` + * + * + * @typeParam T - (optional) any Type of target in GraphModel whose properties shall be removed + * @param {RemoveProperties} properties - properties to remove from target + * properties might be constrained to values of graphModel and keys of target */ - removeProperties(properties: Dictionary>) { + removeProperties = any>( + properties: TypedDictionary, Many>>, + ) { return this.continueChainClause(new Remove({ properties })); } @@ -648,17 +721,57 @@ export abstract class Builder extends SetBlock { * // RETURN DISTINCT people * ``` */ - return(terms: Many, options?: ReturnOptions) { + return( + terms: Many>>, + options?: ReturnOptions, + ) { return this.continueChainClause(new Return(terms, options)); } /** * Shorthand for `return(terms, { distinct: true }); */ - returnDistinct(terms: Many) { + returnDistinct(terms: Many>>) { return this.return(terms, { distinct: true }); } + /** + * Return as object + * + * @param definition - definition of return object as key value pair whereas value can be + * string selector or @see Selector + * + * @typeParam N - (optional) new target type of this cypher to hint the keys that you want + * to be sure of + * + * @example + * ```typescript + * // Selector Object: + * q.returnObject({ + * person: new Selector().set('user', 'name'), + * inventory: new Selector().set('item', 'price'), + * }) + * ``` + * + * ```typescript + * // String selector + * q.returnObject<{user: any}>({ user: 'person.name' }); + * ``` + * + * ```typescript + * // Multiple Objects + * q.returnObject([ + * { person: new Selector().set('user', 'name') }, + * { inventory: 'item.name' }, + * ]) + * ``` + */ + returnObject( + definition: Many>>, + ) : Query { + return this.continueChainClause(new ReturnObject(definition)) as any as Query; + } + /** * Adds a [skip]{@link https://neo4j.com/docs/developer-manual/current/cypher/clauses/skip} * clause to the query. @@ -716,12 +829,14 @@ export abstract class Builder extends SetBlock { * Adds an [unwind]{@link https://neo4j.com/docs/developer-manual/current/cypher/clauses/unwind} * clause to the query. * - * @param {any[]} list Any kind of array to unwind in the query + * @typeParam N - (optional) type of graphModel after this call + * @param {any[]|} list Any kind of array to unwind in the query. If graphModel exists properties + * must exist * @param {string} name Name of the variable to use in the unwinding * @returns {Q} */ - unwind(list: any[], name: string) { - return this.continueChainClause(new Unwind(list, name)); + unwind(list: Selector | any[], name: StringKeyOf): Query { + return this.continueChainClause(new Unwind(list, name)) as any as Query; } /** @@ -910,10 +1025,11 @@ export abstract class Builder extends SetBlock { * * You can also pass an array of any of the above methods. * + * @typeParam N - (optional) type of graphModel after this call * @param {_.Many} terms * @returns {Q} */ - with(terms: Many) { - return this.continueChainClause(new With(terms)); + with(terms: Many | Dictionary>>): Query { + return this.continueChainClause(new With(terms)) as any as Query; } } diff --git a/src/clauses/index.ts b/src/clauses/index.ts index fc7de614..9de5989c 100644 --- a/src/clauses/index.ts +++ b/src/clauses/index.ts @@ -2,6 +2,7 @@ import { Dictionary, Many } from 'lodash'; import { NodePattern } from './node-pattern'; import { RelationDirection, RelationPattern } from './relation-pattern'; import { PathLength } from '../utils'; +import { StringKeyOf, ValueOf } from '../types'; export { Create } from './create'; export { NodePattern } from './node-pattern'; @@ -100,12 +101,12 @@ export { * about escaping. * @returns {NodePattern} An object representing the node pattern. */ -export function node( - name?: Many | Dictionary, +export function node = ValueOf>( + name?: Many> | Dictionary>, labels?: Many | Dictionary, - conditions?: Dictionary, + conditions?: Partial, ) { - return new NodePattern(name, labels, conditions); + return new NodePattern, Partial>(name, labels, conditions); } // Need to disable line length because there is a long link in the documentation @@ -176,12 +177,12 @@ export function node( * @returns {RelationPattern} An object representing the relation pattern. */ /* tslint:disable:max-line-length */ -export function relation( +export function relation = ValueOf>( dir: RelationDirection, - name?: Many | Dictionary | PathLength, + name?: Many> | Dictionary> | PathLength, labels?: Many | Dictionary | PathLength, - conditions?: Dictionary | PathLength, + conditions?: Partial | PathLength, length?: PathLength, ) { - return new RelationPattern(dir, name, labels, conditions, length); + return new RelationPattern, Partial>(dir, name, labels, conditions, length); } diff --git a/src/clauses/match.ts b/src/clauses/match.ts index c76497b2..359828c5 100644 --- a/src/clauses/match.ts +++ b/src/clauses/match.ts @@ -4,9 +4,9 @@ export interface MatchOptions { optional?: boolean; } -export class Match extends PatternClause { +export class Match extends PatternClause { constructor( - patterns: PatternCollection, + patterns: PatternCollection, protected options: MatchOptions = { optional: false }, ) { super(patterns, { useExpandedConditions: true }); diff --git a/src/clauses/node-pattern.spec.ts b/src/clauses/node-pattern.spec.ts index 31f27cb1..a14db1b0 100644 --- a/src/clauses/node-pattern.spec.ts +++ b/src/clauses/node-pattern.spec.ts @@ -35,7 +35,7 @@ describe('Node', () => { }); it('should build a node pattern with just conditions', () => { - const node = new NodePattern(conditions); + const node = new NodePattern(undefined, undefined, conditions); const queryObj = node.buildQueryObject(); expect(queryObj.query).to.equal('({ name: $name, active: $active })'); diff --git a/src/clauses/node-pattern.ts b/src/clauses/node-pattern.ts index 3561fd61..8177cdfa 100644 --- a/src/clauses/node-pattern.ts +++ b/src/clauses/node-pattern.ts @@ -1,11 +1,14 @@ import { Dictionary, Many, trim } from 'lodash'; import { Pattern } from './pattern'; -export class NodePattern extends Pattern { +export class NodePattern< + Names extends string = string, + Condition extends Dictionary = Dictionary + > extends Pattern { constructor( - name?: Many | Dictionary, + name?: Many | Dictionary, labels?: Many | Dictionary, - conditions?: Dictionary, + conditions?: Condition, ) { super(name, labels, conditions); } diff --git a/src/clauses/pattern-clause.ts b/src/clauses/pattern-clause.ts index 598d3d7a..ad63a766 100644 --- a/src/clauses/pattern-clause.ts +++ b/src/clauses/pattern-clause.ts @@ -6,7 +6,14 @@ export interface PatternOptions { useExpandedConditions?: boolean; } -export type PatternCollection = Pattern | Pattern[] | Pattern[][]; +/** + * @typeParam T - (optional) subset of allowed String values + * @typeParam C - (optional) stricter Dictionary for Conditions + */ +export type PatternCollection + = Pattern + | Pattern[] + | Pattern[][]; export class PatternClause extends Clause { protected patterns: Pattern[][]; diff --git a/src/clauses/pattern.ts b/src/clauses/pattern.ts index 47c0202b..0fe567ca 100644 --- a/src/clauses/pattern.ts +++ b/src/clauses/pattern.ts @@ -6,7 +6,14 @@ import { Clause } from '../clause'; import { Parameter } from '../parameter-bag'; import { stringifyLabels } from '../utils'; -export abstract class Pattern extends Clause { +/** + * @typeParam Names - (optional) subset of allowed String values + * @typeParam Condition - (optional) stricter Dictionary + */ +export abstract class Pattern< + Names extends string = string, + Condition extends Dictionary = Dictionary + > extends Clause { protected useExpandedConditions: boolean | undefined; protected conditionParams: Dictionary | Parameter = {}; protected name: string; @@ -14,19 +21,19 @@ export abstract class Pattern extends Clause { protected conditions: Dictionary; constructor( - name?: Many | Dictionary, + name?: Many | Dictionary, labels?: Many | Dictionary, - conditions?: Dictionary, + conditions?: Condition, protected options = { expanded: true }, ) { super(); - const isConditions = (a: any): a is Dictionary => isObjectLike(a) && !isArray(a); - let tempName = name; + // tslint:disable-next-line:max-line-length + const isConditions = (a: unknown): a is Dictionary => isObjectLike(a) && !isArray(a); + let tempName : string|readonly string[]|Dictionary|undefined = name; let tempLabels = labels; - let tempConditions = conditions; - + let tempConditions:object|undefined = conditions; if (isNil(tempConditions)) { - if (isConditions(tempLabels)) { + if (isConditions(tempLabels)) { tempConditions = tempLabels; tempLabels = undefined; } else if (isNil(tempLabels) && isConditions(tempName)) { diff --git a/src/clauses/relation-pattern.ts b/src/clauses/relation-pattern.ts index c28ce165..73f98315 100644 --- a/src/clauses/relation-pattern.ts +++ b/src/clauses/relation-pattern.ts @@ -11,15 +11,22 @@ const isPathLength = (value: any): value is PathLength => ( export type RelationDirection = 'in' | 'out' | 'either'; -export class RelationPattern extends Pattern { +/** + * @typeParam Names - (optional) subset of allowed String values + * @typeParam Condition - (optional) stricter Dictionary + */ +export class RelationPattern< + Names extends string = string, + Condition extends Dictionary = Dictionary + > extends Pattern { dir: RelationDirection; length: PathLength | undefined; constructor( dir: RelationDirection, - name?: Many | Dictionary | PathLength, + name?: Many | Dictionary | Condition | PathLength, labels?: Many | Dictionary | PathLength, - conditions?: Dictionary | PathLength, + conditions?: Condition | PathLength, length?: PathLength, ) { let tempName = name; diff --git a/src/clauses/remove.ts b/src/clauses/remove.ts index 411670e6..f70eac9a 100644 --- a/src/clauses/remove.ts +++ b/src/clauses/remove.ts @@ -1,11 +1,16 @@ import { Clause } from '../clause'; import { Dictionary, Many, map, mapValues, flatMap, castArray } from 'lodash'; import { stringifyLabels } from '../utils'; +import { TypedDictionary } from '../types'; -export type RemoveProperties = { - labels?: Dictionary>; - properties?: Dictionary>; -}; +export type RemoveProperties + = + { + labels?: Dictionary>; + properties?: TypedDictionary>; + }; export class Remove extends Clause { protected labels: Dictionary; diff --git a/src/clauses/returnObject.spec.ts b/src/clauses/returnObject.spec.ts new file mode 100644 index 00000000..2c64db76 --- /dev/null +++ b/src/clauses/returnObject.spec.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; +import { ReturnObject } from './returnObject'; +import { Selector } from '../selector'; + +interface G { user: { name: string }; item: { price: number }; } +type Testcase = { obj: ReturnObject, exp: string }; +describe('returnObject', () => { + const expectations: { [index: string]: Testcase } = { + 'single object by string': { + obj: new ReturnObject({ person: 'user.name' }), + exp: 'RETURN { person: user.name }', + }, + 'single object by selector': { + obj: new ReturnObject({ person: new Selector().set('user', 'name') }), + exp: 'RETURN { person: user.name }', + }, + 'single object multiple fields': { + obj: new ReturnObject({ + person: new Selector().set('user', 'name'), + inventory: 'item.anything', + secureInventory: new Selector().set('item', 'price'), + }), + exp: 'RETURN { person: user.name, inventory: item.anything, secureInventory: item.price }', + }, + 'multiple objects': { + obj: new ReturnObject([ + { person: new Selector().set('user', 'name') }, + { secureInventory: new Selector().set('item', 'price') }, + ]), + exp: 'RETURN { person: user.name }, { secureInventory: item.price }', + }, + 'nested object': { + obj: new ReturnObject({ + nested: { name: new Selector().set('user', 'name') }, + }), + exp: 'RETURN { nested: { name: user.name } }', + }, + }; + + for (const name in expectations) { + const { obj, exp } = expectations[name]; + it(`should build ${name}`, () => { + expect(obj.build()).to.equal(exp); + }); + } +}); diff --git a/src/clauses/returnObject.ts b/src/clauses/returnObject.ts new file mode 100644 index 00000000..b226629d --- /dev/null +++ b/src/clauses/returnObject.ts @@ -0,0 +1,49 @@ +import { Clause } from '../clause'; +import { Many, isArray } from 'lodash'; +import { Selector } from '../selector'; + +/** + * @typeParam G - (optional) current graph model to gather data drom + * @typeParam T - (optional) Target type that defines the output + */ +export type Selectable + = Record | Record>>; + +/** + * Clause to create an object formed RETURN + * + * @typeParam G - (optional) type of graph model that is being queried + */ +export class ReturnObject extends Clause { + constructor(private readonly specs: Many>) { + super(); + } + + build(): string { + const definitions: string[] = []; + const records: Selectable[] = isArray(this.specs) ? this.specs : [this.specs]; + for (const record of records) { + definitions.push(this.stringifyRecord(record)); + } + + return `RETURN ${definitions.join(', ')}`; + } + + private stringifyRecord(record: Selectable) { + let definition = ''; + const properties = []; + for (const key in record) { + const selector = (record[key].toString() === '[object Object]') + ? this.stringifyRecord(>record[key]) + : record[key]; + properties.push({ key, selector }); + } + if (properties.length) { + definition += '{ '; + definition += properties.map(prop => `${prop.key}: ${prop.selector}`).join(', '); + definition += ' }'; + } + + return definition; + } +} diff --git a/src/clauses/term-list-clause.ts b/src/clauses/term-list-clause.ts index f8681fd8..6af16c08 100644 --- a/src/clauses/term-list-clause.ts +++ b/src/clauses/term-list-clause.ts @@ -3,18 +3,18 @@ import { map, isPlainObject, isString, - isArray, castArray, reduce, Dictionary, Many, } from 'lodash'; import { Clause } from '../clause'; +import { TypedDictionary } from '../types'; export type Properties = Many>; -export type Term - = string - | Dictionary; +export type Term + = T + | TypedDictionary; export class TermListClause extends Clause { protected terms: Term[]; @@ -44,7 +44,7 @@ export class TermListClause extends Clause { private stringifyTerm(term: Term): string[] { // Just a node if (isString(term)) { - return [this.stringifyProperty(term)]; + return [TermListClause.stringifyProperty(term)]; } // Node properties or aliases @@ -55,7 +55,7 @@ export class TermListClause extends Clause { return []; } - private stringifyProperty(prop: string, alias?: string, node?: string): string { + private static stringifyProperty(prop: string, alias?: string, node?: string): string { const prefixString = node ? `${node}.` : ''; const aliasString = alias ? `${alias} AS ` : ''; return prefixString + aliasString + prop; @@ -65,10 +65,12 @@ export class TermListClause extends Clause { const convertToString = (list: string[], prop: string | Dictionary) => { if (isString(prop)) { // Single node property - list.push(this.stringifyProperty(prop, alias, node)); + list.push(TermListClause.stringifyProperty(prop, alias, node)); } else { // Node properties with aliases - list.push(...map(prop, (name, alias) => this.stringifyProperty(name, alias, node))); + list.push( + ...map(prop, (name, alias) => TermListClause.stringifyProperty(name, alias, node)), + ); } return list; }; @@ -81,7 +83,7 @@ export class TermListClause extends Clause { (list, prop, key) => { if (isString(prop)) { // Alias - list.push(this.stringifyProperty(prop, key)); + list.push(TermListClause.stringifyProperty(prop, key)); } else { // Node with properties list.push(...this.stringifyProperties(prop, undefined, key)); diff --git a/src/clauses/unwind.spec.ts b/src/clauses/unwind.spec.ts index 129a3b1a..26da4a93 100644 --- a/src/clauses/unwind.spec.ts +++ b/src/clauses/unwind.spec.ts @@ -1,5 +1,6 @@ import { Unwind } from './unwind'; import { expect } from 'chai'; +import { Selector } from '../selector'; describe('Unwind', () => { it('should start with Unwind', () => { @@ -12,4 +13,8 @@ describe('Unwind', () => { const clause = new Unwind(list, 'node'); expect(clause.getParams()).to.deep.equal({ list }); }); + it('should unwind query lists by name', () => { + const clause = new Unwind(new Selector().set('property'), 'node'); + expect(clause.build()).to.equal('UNWIND property AS node'); + }); }); diff --git a/src/clauses/unwind.ts b/src/clauses/unwind.ts index 5792bcf9..fe1319a3 100644 --- a/src/clauses/unwind.ts +++ b/src/clauses/unwind.ts @@ -1,15 +1,20 @@ import { Clause } from '../clause'; import { Parameter } from '../parameter-bag'; +import { Selector } from '../selector'; export class Unwind extends Clause { - protected listParam: Parameter; + protected listParam: Parameter|string; constructor( - protected list: any[], + protected list: any[]|Selector, protected name: string, ) { super(); - this.listParam = this.parameterBag.addParam(this.list, 'list'); + if (list instanceof Selector) { + this.listParam = list.toString(); + } else { + this.listParam = this.parameterBag.addParam(this.list, 'list'); + } } build() { diff --git a/src/connection.ts b/src/connection.ts index 70679bae..1746bd95 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -88,9 +88,11 @@ const isTrueFunction: (value: any) => value is Function = isFunction; * * The library will attempt to clean up all connections when the process exits, but it is better to * be explicit. + * + * @typeParam {Builder} G - GraphModel that is currently processable. Defaults to Dictionary + * but can be something more specific like a model of your graph with all its properties */ -// tslint:enable max-line-length -export class Connection extends Builder { +export class Connection extends Builder { protected auth: AuthToken; protected driver: Driver; protected options: FullConnectionOptions; @@ -165,8 +167,8 @@ export class Connection extends Builder { * new chainable query for you. * @return {Query} */ - query(): Query { - return new Query(this); + query(): Query { + return new Query(this); } protected continueChainClause(clause: Clause) { diff --git a/src/index.ts b/src/index.ts index ba39cadc..cb565e25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,6 @@ export * from './clause'; export * from './clause-collection'; export * from './clauses'; export * from './query'; +export * from './selector'; export * from './transformer'; +export * from './types'; diff --git a/src/query.spec.ts b/src/query.spec.ts index 89f89e43..1f9f044f 100644 --- a/src/query.spec.ts +++ b/src/query.spec.ts @@ -6,7 +6,9 @@ import { ClauseCollection } from './clause-collection'; import { node, NodePattern } from './clauses'; import { Query } from './query'; import { Observable } from 'rxjs'; +import { Selector } from './selector'; +interface GraphModel { user: { name: string }; item : { price : number }; } describe('Query', () => { describe('query methods', () => { const methods: Dictionary<(q: Query) => Query> = { @@ -45,7 +47,7 @@ describe('Query', () => { each(methods, (fn, name) => { it(`${name} should return a chainable query object`, () => { const query = new Query(); - expect(fn(query)).to.equal(query); + expect(fn(query)).to.be.instanceof(Query); expect(query.getClauses().length === 1); expect(query.build()).to.equal(`${query.getClauses()[0].build()};`); }); @@ -150,4 +152,45 @@ describe('Query', () => { }); }); }); + + type expectation = [(q: Query) => Query, string]; + describe('method functionality', () => { + const expectations : Record = { + returnSingleString: [(q: Query) => q.return('people'), 'RETURN people;'], + returnArrayOfStrings: [(q: Query) => q.return(['people', 'pets']), 'RETURN people, pets;'], + returnSingleObject: [(q: Query) => q.return({ people: 'employees' }), + 'RETURN people AS employees;'], + returnSingleObjectWithArrayValues: [(q: Query) => q.return({ + people: ['name', 'age'], + pets: ['name', 'breed'], + }), 'RETURN people.name, people.age, pets.name, pets.breed;'], + returnPropsAliased: [(q: Query) => q.return( + { people: [{ name: 'personName' }, 'age'] }, + ), 'RETURN people.name AS personName, people.age;'], + returnDistinct: [(q: Query) => q.return('people', { distinct: true }), + 'RETURN DISTINCT people;'], + returnObject: [(q: Query) => q.returnObject({ + user: 'person.name'}), 'RETURN { user: person.name };'], + returnObjectTyped: [(q: Query) => q.returnObject( + { user: 'person.name' }), 'RETURN { user: person.name };'], + 'return object w/ selector': [(q: Query) => q.returnObject( + { person: new Selector().set('user', 'name') }), 'RETURN { person: user.name };'], + 'return object w/ multiple selectors': [(q: Query) => q.returnObject({ + person: new Selector().set('user', 'name'), + inventory: new Selector().set('item', 'price'), + }), 'RETURN { person: user.name, inventory: item.price };'], + 'return many typed objects': [(q: Query) => q.returnObject([ + { person: new Selector().set('user', 'name') }, + { inventory: 'item.name' }, + ]), 'RETURN { person: user.name }, { inventory: item.name };'], + }; + + Object.entries(expectations).forEach( + ([name, [fn, exp]] : [string, expectation]) => { + it(`"${name}" should build`, () => { + const q = new Query(); + expect(fn(q).build()).to.equal(exp); + }); + }); + }); }); diff --git a/src/query.ts b/src/query.ts index f4c49fde..c7cc4a17 100644 --- a/src/query.ts +++ b/src/query.ts @@ -5,19 +5,28 @@ import { Builder } from './builder'; import { ClauseCollection } from './clause-collection'; import { Clause, QueryObject } from './clause'; -export class Query extends Builder { +/** + * @typeParam G - (optional) Graph Model type. Defines the schema and available data in this cypher + * You typically provide this during match definitions when you define what is being queried + * + * @typeParam R - (optional) Return Type of this cypher. You typically provide this during + * @see returnObject() + */ +export class Query extends Builder, G> { protected clauses = new ClauseCollection(); /** * Creates a new query with a given connection. * * @param {Connection} connection + * @param {clauses} clauses e.g. to clone the state of an query */ - constructor(protected connection: Connection | null = null) { + constructor(protected connection: Connection | null = null, clauses? : ClauseCollection) { super(); + this.clauses = clauses ?? new ClauseCollection(); } - protected continueChainClause(clause: Clause) { + protected continueChainClause(clause: Clause) : Query { return this.addClause(clause); } diff --git a/src/selector.spec.ts b/src/selector.spec.ts new file mode 100644 index 00000000..9b1c9360 --- /dev/null +++ b/src/selector.spec.ts @@ -0,0 +1,21 @@ +import { describe } from 'mocha'; +import { Selector } from './selector'; +import { expect } from '../test-setup'; + +type graph = { user : { name : string, id : number}, item : { price : number }}; +describe('Selector', () => { + describe('toString', () => { + const expectations : {[key : string] : [Selector, string]} = { + 'with graph model and without property': + [new Selector().set('user'), 'user'], + 'with graph model and with property': + [new Selector().set('user', 'name'), 'user.name'], + }; + for (const name in expectations) { + it(`${name} should build`, () => { + const [obj, exp] = expectations[name]; + expect((>obj).toString()).to.equal(exp); + }); + } + }); +}); diff --git a/src/selector.ts b/src/selector.ts new file mode 100644 index 00000000..e43eba41 --- /dev/null +++ b/src/selector.ts @@ -0,0 +1,29 @@ +/** + * Selectors can be used to query nested fields in the GraphModel + * + * @example + * ```typescript + * interface gm = { user: { name: string } }; + * new Selector().set('user', 'name') }; + * ``` + */ +export class Selector { + get tuple(): [keyof G, any?] { + if (this._tuple === undefined) { + throw new Error('Uninitialized selector'); + } + return this._tuple; + } + set(key: T, prop? : A) { + this._tuple = [key, prop]; + return this; + } + + // tslint:disable-next-line:variable-name + private _tuple: [keyof G, any?]|undefined; + + toString() { + const prop : string = (this.tuple[1] !== undefined) ? `.${this.tuple[1]}` : ''; + return `${this.tuple[0]}${prop}`; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..a51876eb --- /dev/null +++ b/src/types.ts @@ -0,0 +1,8 @@ +export type ValueOf = T[keyof T]; +export type StringKeyOf = Extract; + +export type TypedDictionary = { + [key in K]? : V +}; diff --git a/yarn.lock b/yarn.lock index 1a0f176a..1c43f01b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -505,11 +505,16 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.18.tgz#8d16634797d63c2af5bc647ce879f8de20b56469" integrity sha512-DBkZuIMFuAfjJHiunyRc+aNvmXYNwV1IPMgGKGlwCp6zh6MKrVtmvjSWK/axWcD25KJffkXgkfvFra8ndenXAw== -"@types/node@^12.0.2", "@types/node@^12.6.1": +"@types/node@^12.0.2": version "12.12.14" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.14.tgz#1c1d6e3c75dba466e0326948d56e8bd72a1903d2" integrity sha512-u/SJDyXwuihpwjXy7hOOghagLEV1KdAST6syfnOk6QZAMzZuWZqXy5aYYZbh8Jdpd4escVFP0MvftHNDb9pruA== +"@types/node@^12.19.9": + version "12.19.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.19.9.tgz#990ad687ad8b26ef6dcc34a4f69c33d40c95b679" + integrity sha512-yj0DOaQeUrk3nJ0bd3Y5PeDRJ6W0r+kilosLA+dzF3dola/o9hxhMSg2sFvVcA2UHS5JSOsZp4S0c1OEXc4m1Q== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"