From 9506b09df2cf07c7caa8063525ba97735282ccca Mon Sep 17 00:00:00 2001 From: Ardalan Amini Date: Wed, 14 Nov 2018 20:58:57 +0330 Subject: [PATCH] Update Version --- CHANGELOG.md | 6 + package-lock.json | 43 ++- package.json | 8 +- src/DB.ts | 2 + src/Model.ts | 8 +- src/base/QueryBuilder.ts | 5 +- src/drivers/Driver.ts | 49 ++- src/drivers/MongoDB/Driver/Filter.ts | 149 ++++++++ src/drivers/MongoDB/Driver/Join.ts | 117 ++++++ .../MongoDB/{Driver.ts => Driver/index.ts} | 345 ++++++------------ src/drivers/MongoDB/Relation/HasMany.ts | 2 +- src/drivers/MongoDB/Relation/HasOne.ts | 2 +- src/drivers/MongoDB/Relation/MorphMany.ts | 4 +- src/drivers/MongoDB/Relation/MorphOne.ts | 4 +- src/drivers/MongoDB/utils.ts | 24 ++ src/types/Date.ts | 16 +- src/types/Object.ts | 2 + src/types/String.ts | 17 + src/types/index.ts | 17 +- test/Model.ts | 24 +- test/drivers/MongoDB.ts | 2 +- tslint.json | 1 + 22 files changed, 540 insertions(+), 307 deletions(-) create mode 100644 src/drivers/MongoDB/Driver/Filter.ts create mode 100644 src/drivers/MongoDB/Driver/Join.ts rename src/drivers/MongoDB/{Driver.ts => Driver/index.ts} (57%) create mode 100644 src/drivers/MongoDB/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a17a1d..5b5ab30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ --- +## [v0.2.0](https://github.com/foxifyjs/odin/releases/tag/v0.2.0) - *(2018-11-14)* + +- :zap: Added more advanced filtering ability to queries and joins +- :zap: Added `numeral` to `String` schema type +- :zap: Added `enum` to `String` schema type + ## [v0.1.0](https://github.com/foxifyjs/odin/releases/tag/v0.1.0) - *(2018-10-20)* - :zap: Added model hook `created` diff --git a/package-lock.json b/package-lock.json index a036359..a0239cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@foxify/odin", - "version": "0.1.3", + "version": "0.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -130,9 +130,9 @@ } }, "@types/node": { - "version": "10.12.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.3.tgz", - "integrity": "sha512-sfGmOtSMSbQ/AKG8V9xD1gmjquC9awIIZ/Kj309pHb2n3bcRAcGMQv5nJ6gCXZVsneGE4+ve8DXKRCsrg3TFzg==" + "version": "10.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.7.tgz", + "integrity": "sha512-Zh5Z4kACfbeE8aAOYh9mqotRxaZMro8MbBQtR8vEXOMiZo2rGEh2LayJijKdlu48YnS6y2EFU/oo2NCe5P6jGw==" }, "abab": { "version": "2.0.0", @@ -976,7 +976,7 @@ }, "bl": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "resolved": "http://registry.npmjs.org/bl/-/bl-1.2.2.tgz", "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", "dev": true, "requires": { @@ -1045,7 +1045,7 @@ }, "buffer": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-3.6.0.tgz", + "resolved": "http://registry.npmjs.org/buffer/-/buffer-3.6.0.tgz", "integrity": "sha1-pyyTb3e5a/UvX357RnGAYoVR3vs=", "dev": true, "requires": { @@ -1381,12 +1381,12 @@ } }, "deasync": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/deasync/-/deasync-0.1.13.tgz", - "integrity": "sha512-/6ngYM7AapueqLtvOzjv9+11N2fHDSrkxeMF1YPE20WIfaaawiBg+HZH1E5lHrcJxlKR42t6XPOEmMmqcAsU1g==", + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/deasync/-/deasync-0.1.14.tgz", + "integrity": "sha512-wN8sIuEqIwyQh72AG7oY6YQODCxIp1eXzEZlZznBuwDF8Q03Tdy9QNp1BNZXeadXoklNrw+Ip1fch+KXo/+ASw==", "requires": { "bindings": "~1.2.1", - "nan": "^2.0.7" + "node-addon-api": "^1.6.0" } }, "debug": { @@ -1483,13 +1483,13 @@ "dependencies": { "file-type": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "resolved": "http://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=", "dev": true }, "get-stream": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", "dev": true, "requires": { @@ -1703,7 +1703,7 @@ }, "es6-promisify": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "resolved": "http://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", "dev": true, "requires": { @@ -4172,9 +4172,9 @@ } }, "mongodb-memory-server": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-2.7.0.tgz", - "integrity": "sha512-T9zBEN3/y7/s4F83B2jAlLHtjjSEp50GQ2J0b7QMbAwM/G7Rkxzdf3cCfzOChDBfI0lQto09EOTdDam6mm0REQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-2.7.2.tgz", + "integrity": "sha512-qT/iSiQf2cpBrz7Bln5Va2+rd2evkTPTO2HbQW5eZwaShPcSxkL9xhUHuemG/9+0ALUjrF9Dq+Mxvur2YRF24Q==", "dev": true, "requires": { "@babel/runtime": "^7.1.2", @@ -4228,7 +4228,9 @@ "nan": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", - "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==" + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "dev": true, + "optional": true }, "nanomatch": { "version": "1.2.13", @@ -4275,6 +4277,11 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node-addon-api": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.6.0.tgz", + "integrity": "sha512-HEUPBHfdH4CLR1Qq4/Ek8GT/qFSvpApjJQmcYdLCL51ADU/Y11kMuFAdIevhNrPh3ylqVGA8k6vI/oi4YUAHbA==" + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5816,7 +5823,7 @@ }, "through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, diff --git a/package.json b/package.json index 66c651c..89390f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxify/odin", - "version": "0.1.3", + "version": "0.2.0", "description": "Active Record Model", "author": "Ardalan Amini [https://github.com/ardalanamini]", "contributors": [ @@ -43,10 +43,10 @@ "@types/graphql": "^0.13.4", "@types/graphql-iso-date": "^3.3.1", "@types/mongodb": "^3.1.14", - "@types/node": "^10.12.3", + "@types/node": "^10.12.7", "async": "^2.6.1", "caller-id": "^0.1.0", - "deasync": "^0.1.13", + "deasync": "^0.1.14", "graphql": "^0.13.2", "graphql-iso-date": "^3.6.1", "mongodb": "^3.1.9", @@ -63,7 +63,7 @@ "dotenv": "^6.1.0", "fs-readdir-recursive": "^1.1.0", "jest": "^23.6.0", - "mongodb-memory-server": "^2.7.0", + "mongodb-memory-server": "^2.7.2", "rimraf": "^2.6.2", "ts-jest": "^23.10.4", "tslint": "^5.11.0", diff --git a/src/DB.ts b/src/DB.ts index 6ce6961..265347c 100644 --- a/src/DB.ts +++ b/src/DB.ts @@ -56,6 +56,7 @@ class DB = any, A = undefined> { /******************************* Where Clauses ******************************/ + public where(query: Driver.FilterQuery): this; public where(field: string, value: any): this; public where(field: string, operator: Driver.Operator, value: any): this; public where() { @@ -66,6 +67,7 @@ class DB = any, A = undefined> { return this; } + public orWhere(query: Driver.FilterQuery): this; public orWhere(field: string, value: any): this; public orWhere(field: string, operator: Driver.Operator, value: any): this; public orWhere() { diff --git a/src/Model.ts b/src/Model.ts index 955c402..9acb1fe 100644 --- a/src/Model.ts +++ b/src/Model.ts @@ -20,7 +20,6 @@ class Model extends Base implements QueryBuilder, Relational, GraphQLInstance { public static DB = DB; public static GraphQL = GraphQL; - public static Types = Types; public static connections = connections; public static connection: Odin.Connection = "default"; @@ -44,9 +43,8 @@ class Model extends Base } private static get _schema() { - // TODO: id const schema: Odin.Schema = { - id: this.Types.ObjectId, + id: this.Types.Id, ...this.schema, }; @@ -61,6 +59,10 @@ class Model extends Base return schema; } + public static get Types() { + return new Types(this.driver); + } + public static get driver() { return getConnection(this.connection).driver; } diff --git a/src/base/QueryBuilder.ts b/src/base/QueryBuilder.ts index 292cc78..012cebb 100644 --- a/src/base/QueryBuilder.ts +++ b/src/base/QueryBuilder.ts @@ -29,7 +29,7 @@ class QueryBuilder extends Base { throw new TypeError("Expected at least one 'relation', got none"); return this.query(relations.map((name) => { - let query = (this.prototype as any)[name] as any; + let query = (this.prototype as any)[name] as Relation; if (!utils.function.isFunction(query)) throw new Error(`Relation '${name}' does not exist on '${this.name}' Model`); @@ -53,10 +53,11 @@ class QueryBuilder extends Base { /******************************* Where Clauses ******************************/ + public static where(query: Driver.FilterQuery): Query; public static where(field: string, value: any): Query; public static where( field: string, operator: Driver.Operator, value: any): Query; - public static where(field: string, operator: Driver.Operator | any, value?: any) { + public static where(field: any, operator?: Driver.Operator | any, value?: any) { return this.query().where(field, operator, value); } diff --git a/src/drivers/Driver.ts b/src/drivers/Driver.ts index a6085df..b0fce90 100644 --- a/src/drivers/Driver.ts +++ b/src/drivers/Driver.ts @@ -1,28 +1,53 @@ -import * as mongodb from "mongodb"; import * as connections from "../connections"; -module Driver { +namespace Driver { export type Callback = (error: Error, result: T) => void; export type Operator = "<" | "<=" | "=" | "<>" | ">=" | ">"; export type Order = "asc" | "desc"; - export type Id = number | mongodb.ObjectId; + export type Id = number | string; - export interface GroupQueryObject { - having: (field: string, operator: Operator | any, value?: any) => GroupQueryObject; + export interface Filter { + where(query: FilterQuery): this; + where(field: string, value: any): this; + where(field: string, operator: Driver.Operator, value: any): this; + + orWhere(query: FilterQuery): this; + orWhere(field: string, value: any): this; + orWhere(field: string, operator: Driver.Operator, value: any): this; + + whereLike(field: string, value: any): this; + + whereNotLike(field: string, value: any): this; + + whereIn(field: string, values: any[]): this; + + whereNotIn(field: string, values: any[]): this; + + whereBetween(field: string, start: any, end: any): this; + + whereNotBetween(field: string, start: any, end: any): this; + + whereNull(field: string): this; + + whereNotNull(field: string): this; } - export type GroupQuery = (query: GroupQueryObject) => void; + export type FilterQuery = (query: Filter) => Filter; - export interface JoinQueryObject { - on: (field: string, operator: Operator | any, value?: any) => JoinQueryObject; + export interface Join extends Filter { + join(table: string, query?: Driver.JoinQuery, as?: string): this; } - export type JoinQuery = (query: JoinQueryObject) => void; + export type JoinQuery = (query: Join) => Join; + + export interface GroupQueryObject { + having: (field: string, operator: Operator | any, value?: any) => GroupQueryObject; + } - export type WhereQuery = (query: Driver) => Driver; + export type GroupQuery = (query: GroupQueryObject) => void; export type Mapper = (item: T, index: number, items: T[]) => any; } @@ -44,11 +69,11 @@ abstract class Driver { /******************************* Where Clauses ******************************/ - // abstract where(query: Driver.WhereQuery): this; + public abstract where(query: Driver.FilterQuery): this; public abstract where(field: string, value: any): this; public abstract where(field: string, operator: Driver.Operator, value: any): this; - // abstract orWhere(query: Driver.WhereQuery): this; + public abstract orWhere(query: Driver.FilterQuery): this; public abstract orWhere(field: string, value: any): this; public abstract orWhere(field: string, operator: Driver.Operator, value: any): this; diff --git a/src/drivers/MongoDB/Driver/Filter.ts b/src/drivers/MongoDB/Driver/Filter.ts new file mode 100644 index 0000000..095d420 --- /dev/null +++ b/src/drivers/MongoDB/Driver/Filter.ts @@ -0,0 +1,149 @@ +import * as mongodb from "mongodb"; +import { function as func } from "../../../utils"; +import Driver from "../../Driver"; +import { OPERATORS, prepareKey, prepareValue } from "../utils"; + +namespace Filter { + export interface Filters { + $and?: Array>; + $or?: Array>; + [operator: string]: any; + } +} + +class Filter { + protected _filters: Filter.Filters = { + $and: [], + }; + + get filters() { + const FILTERS = { + ...this._filters, + }; + + if (FILTERS.$and && FILTERS.$and.length === 0) delete FILTERS.$and; + else if (FILTERS.$or && FILTERS.$or.length === 0) delete FILTERS.$or; + + return FILTERS; + } + + /********************************** Helpers *********************************/ + + protected _push_filter(operator: "and" | "or", value: any) { + const filters = { ...this._filters }; + + if (operator === "and" && filters.$or) { + filters.$and = [this._filters]; + delete filters.$or; + } else if (operator === "or" && filters.$and) { + filters.$or = [this._filters]; + delete filters.$and; + } + + filters[`$${operator}`].push(value); + + this._filters = filters; + + return this; + } + + protected _where(field: string, operator: string, value: any) { + field = prepareKey(field); + value = prepareValue(field, value); + + return this._push_filter("and", { + [field]: { + [`$${operator}`]: value, + }, + }); + } + + protected _or_where(field: string, operator: string, value: any) { + field = prepareKey(field); + value = prepareValue(field, value); + + return this._push_filter("or", { + [field]: { + [`$${operator}`]: value, + }, + }); + } + + /******************************* Where Clauses ******************************/ + + public where(query: Driver.FilterQuery): this; + public where(field: string, value: any): this; + public where(field: string, operator: Driver.Operator, value: any): this; + public where(field: string | Driver.FilterQuery, operator?: Driver.Operator | any, value?: any) { + if (func.isFunction(field)) { + const filter: Filter = field(new Filter()) as any; + + return this._push_filter("and", filter.filters); + } + + if (value === undefined) { + value = operator; + operator = "="; + } + + return this._where(field, OPERATORS[operator], value); + } + + public orWhere(query: Driver.FilterQuery): this; + public orWhere(field: string, value: any): this; + public orWhere(field: string, operator: Driver.Operator, value: any): this; + public orWhere(field: string | Driver.FilterQuery, operator?: Driver.Operator | any, value?: any) { + if (func.isFunction(field)) { + const filter: Filter = field(new Filter()) as any; + + return this._push_filter("or", filter.filters); + } + + if (value === undefined) { + value = operator; + operator = "="; + } + + return this._or_where(field, OPERATORS[operator], value); + } + + public whereLike(field: string, value: any) { + if (!(value instanceof RegExp)) value = new RegExp(value, "i"); + + return this._where(field, "regex", value); + } + + public whereNotLike(field: string, value: any) { + if (!(value instanceof RegExp)) value = new RegExp(value, "i"); + + return this._where(field, "not", value); + } + + public whereIn(field: string, values: any[]) { + return this._where(field, "in", values); + } + + public whereNotIn(field: string, values: any[]) { + return this._where(field, "nin", values); + } + + public whereBetween(field: string, start: any, end: any) { + return this._where(field, "gte", start) + ._where(field, "lte", end); + } + + public whereNotBetween(field: string, start: any, end: any) { + return this._where(field, "lt", start) + ._or_where(field, "gt", end); + } + + public whereNull(field: string) { + return this._where(field, "eq", null); + } + + public whereNotNull(field: string) { + return this._where(field, "ne", null); + } +} + +export default Filter; diff --git a/src/drivers/MongoDB/Driver/Join.ts b/src/drivers/MongoDB/Driver/Join.ts new file mode 100644 index 0000000..a98dfac --- /dev/null +++ b/src/drivers/MongoDB/Driver/Join.ts @@ -0,0 +1,117 @@ +import { array, makeTableId, object, string } from "../../../utils"; +import Driver from "../../Driver"; +import { OPERATORS, prepareKey } from "../utils"; +import Filter from "./Filter"; + +class Join extends Filter { + protected _pipeline: object[] = []; + + protected _let: { [key: string]: any } = {}; + + protected _filters: Filter.Filters = { + $and: [], + }; + + public get pipeline() { + this._resetFilters(); + + return { + $lookup: { + as: this._as, + from: this._table, + let: this._let, + pipeline: this._pipeline, + }, + }; + } + + constructor( + protected _ancestor: string, + protected _table: string, + protected _as: string = _table + ) { + super(); + } + + /********************************** Helpers *********************************/ + + protected _resetFilters() { + const FILTER = this.filters; + + if (object.size(FILTER) > 0) { + this._pipeline.push({ $match: FILTER }); + + this._filters = { + $and: [], + }; + } + + return this; + } + + protected _shouldPushExpr(value: any) { + if (!string.isString(value)) return false; + + return new RegExp(`^${this._ancestor}\..+`).test(value); + } + + protected _where(field: string, operator: string, value: any) { + if (this._shouldPushExpr(value)) { + const keys = array.tail(value.split(".")); + + keys.push(prepareKey(keys.pop() as string)); + + const key = keys.join("."); + const pivotKey = `pivot_${key}`; + + this._let[pivotKey] = `$${key}`; + + return this._push_filter("and", { + $expr: { + [`$${operator}`]: [`$${prepareKey(field)}`, `$$${pivotKey}`], + }, + }); + } + + return super._where(field, operator, value); + } + + protected _or_where(field: string, operator: string, value: any) { + if (this._shouldPushExpr(value)) { + const keys = array.tail(value.split(".")); + + keys.push(prepareKey(keys.pop() as string)); + + const key = keys.join("."); + const pivotKey = `pivot_${key}`; + + this._let[pivotKey] = `$${key}`; + + return this._push_filter("or", { + $expr: { + [`$${operator}`]: [`$${prepareKey(field)}`, `$$${pivotKey}`], + }, + }); + } + + return super._or_where(field, operator, value); + } + + /*********************************** Joins **********************************/ + + public join( + table: string, + query: Driver.JoinQuery = q => q.where(makeTableId(table), `${table}.id`), + as: string = table + ) { + const join: Join = query(new Join(this._table, table, as)) as any; + + this._resetFilters(); + + this._pipeline.push(join.pipeline); + + return this; + } +} + +export default Join; diff --git a/src/drivers/MongoDB/Driver.ts b/src/drivers/MongoDB/Driver/index.ts similarity index 57% rename from src/drivers/MongoDB/Driver.ts rename to src/drivers/MongoDB/Driver/index.ts index 33beba1..814947b 100644 --- a/src/drivers/MongoDB/Driver.ts +++ b/src/drivers/MongoDB/Driver/index.ts @@ -1,100 +1,14 @@ import * as deasync from "deasync"; import * as mongodb from "mongodb"; -import { connect, Query } from "../../connections"; -import * as utils from "../../utils"; -import Base from "../Driver"; +import { connect, Query } from "../../../connections"; +import * as utils from "../../../utils"; +import Base from "../../Driver"; +import { isID, OPERATORS, prepareKey, prepareValue } from "../utils"; +import Filter from "./Filter"; +import Join from "./Join"; const { ObjectId } = mongodb; -const isID = (id: string) => /(_id$|^id$)/.test(id); - -const prepareKey = (id: string) => id === "id" ? "_id" : id; - -const prepareValue = (field: string, value: any) => { - if (!isID(field)) return value; - - if (Array.isArray(value)) return value.map(v => new ObjectId(v)); - - if (!ObjectId.isValid(value)) return value; - - return new ObjectId(value); -}; - -const OPERATORS: { [operator: string]: string } = { - "<": "lt", - "<=": "lte", - "=": "eq", - "<>": "ne", - ">=": "gte", - ">": "gt", -}; - -module Driver { - export interface Filters { - $and?: Array>; - $or?: Array>; - [operator: string]: any; - } -} - -interface Driver extends Base { - /*********************************** Joins **********************************/ - - /******************************* Where Clauses ******************************/ - - /*************** Mapping, Ordering, Grouping, Limit & Offset ****************/ - - /*********************************** Read ***********************************/ - - exists(): Promise; - exists(callback: mongodb.MongoCallback): void; - - count(): Promise; - count(callback: mongodb.MongoCallback): void; - - get(fields?: string[]): Promise; - get(fields: string[], callback: mongodb.MongoCallback): void; - get(callback: mongodb.MongoCallback): void; - - first(fields?: string[]): Promise; - first(fields: string[], callback: mongodb.MongoCallback): void; - first(callback: mongodb.MongoCallback): void; - - value(field: string): Promise; - value(field: string, callback: mongodb.MongoCallback): void; - - max(field: string): Promise; - max(field: string, callback: mongodb.MongoCallback): void; - - min(field: string): Promise; - min(field: string, callback: mongodb.MongoCallback): void; - - avg(field: string): Promise; - avg(field: string, callback: mongodb.MongoCallback): void; - - /********************************** Inserts *********************************/ - - insert(item: T | T[]): Promise; - insert(item: T | T[], callback: mongodb.MongoCallback): void; - - insertGetId(item: T): Promise; - insertGetId(item: T, callback: mongodb.MongoCallback): void; - - /********************************** Updates *********************************/ - - update(update: T): Promise; - update(update: T, callback: mongodb.MongoCallback): void; - - increment(field: string, count?: number): Promise; - increment(field: string, callback: mongodb.MongoCallback): void; - increment(field: string, count: number, callback: mongodb.MongoCallback): void; - - /********************************** Deletes *********************************/ - - delete(): Promise; - delete(callback: mongodb.MongoCallback): void; -} - class Driver extends Base { public static connect(con: connect.Connection) { if (con.connection) @@ -119,22 +33,14 @@ class Driver extends Base { protected _query!: mongodb.Collection; - protected _filters: Driver.Filters = { - $and: [], - }; + protected _filter = new Filter(); private _pipeline: object[] = []; private _mappers: Array> = []; - protected get _filter() { - const filter = { - ...this._filters, - }; - - if (filter.$and && filter.$and.length === 0) delete filter.$and; - - return filter; + protected get _filters() { + return this._filter.filters; } constructor(query: Query) { @@ -181,6 +87,18 @@ class Driver extends Base { ); } + protected _resetFilters() { + const FILTER = this._filters; + + if (utils.object.size(FILTER) > 0) { + this._pipeline.push({ $match: FILTER }); + + this._filter = new Filter(); + } + + return this; + } + public table(table: string) { if (!(this._query as any).collection) throw new Error("Can't change table name in the middle of query"); @@ -204,170 +122,92 @@ class Driver extends Base { public join( table: string, - query: Base.JoinQuery = q => q.on(utils.makeTableId(table), `${table}.id`), + query: Base.JoinQuery = q => q.where(utils.makeTableId(table), `${table}.id`), as: string = table ) { - const LET: { [key: string]: any } = {}; - const EXPR_MATCH: object[] = []; - const MATCH: object[] = []; - - const QUERY = { - on: (field: any, operator: any, value?: any) => { - field = prepareKey(field); - - if (value === undefined) { - value = operator; - operator = "="; - } - - if (utils.string.isString(value) && new RegExp(`^${this._table}\..+`).test(value)) { - const keys = utils.array.tail(value.split(".")); - - keys.push(prepareKey(keys.pop() as string)); - - const key = keys.join("."); - const pivotKey = `pivot_${key}`; - - LET[pivotKey] = `$${key}`; - - EXPR_MATCH.push({ - [`$${OPERATORS[operator]}`]: [`$${field}`, `$$${pivotKey}`], - }); - - return QUERY; - } - - value = prepareValue(field, value); - - MATCH.push({ - [field]: { - [`$${OPERATORS[operator]}`]: value, - }, - }); - - return QUERY; - }, - }; - - query(QUERY); + const join: Join = query(new Join(this._table, table, as)) as any; - const PIPELINE: object[] = []; - - if (EXPR_MATCH.length > 0) PIPELINE.push({ - $match: { - $expr: { - $and: EXPR_MATCH, - }, - }, - }); + this._resetFilters(); - if (MATCH.length > 0) PIPELINE.push({ - $match: { - $and: MATCH, - }, - }); + this._pipeline.push(join.pipeline); - return this.pipeline({ - $lookup: { as, from: table, let: LET, pipeline: PIPELINE }, - }); + return this; } /******************************* Where Clauses ******************************/ - private _push_filter(operator: "and" | "or", value: any) { - const filters = { ...this._filters }; - - if (operator === "and" && filters.$or) { - filters.$and = [this._filters]; - delete filters.$or; - } else if (operator === "or" && filters.$and) { - filters.$or = [this._filters]; - delete filters.$and; - } - - filters[`$${operator}`].push(value); - - this._filters = filters; + public where(query: Base.FilterQuery): this; + public where(field: string, value: any): this; + public where(field: string, operator: Base.Operator, value: any): this; + public where() { + this._filter = this._filter.where.apply(this._filter, arguments); return this; } - private _where(field: string, operator: string, value: any) { - field = prepareKey(field); - value = prepareValue(field, value); + public orWhere(query: Base.FilterQuery): this; + public orWhere(field: string, value: any): this; + public orWhere(field: string, operator: Base.Operator, value: any): this; + public orWhere() { + this._filter = this._filter.orWhere.apply(this._filter, arguments); - return this._push_filter("and", { - [field]: { - [`$${operator}`]: value, - }, - }); + return this; } - private _or_where(field: string, operator: string, value: any) { - field = prepareKey(field); - value = prepareValue(field, value); + public whereLike(field: string, value: any): this; + public whereLike() { + this._filter = this._filter.whereLike.apply(this._filter, arguments); - return this._push_filter("or", { - [field]: { - [`$${operator}`]: value, - }, - }); + return this; } - public where(field: string, operator: Base.Operator | any, value?: any) { - if (value === undefined) { - value = operator; - operator = "="; - } + public whereNotLike(field: string, value: any): this; + public whereNotLike() { + this._filter = this._filter.whereNotLike.apply(this._filter, arguments); - return this._where(field, OPERATORS[operator], value); + return this; } - public orWhere(field: string, operator: Base.Operator | any, value?: any) { - if (value === undefined) { - value = operator; - operator = "="; - } + public whereIn(field: string, values: any[]): this; + public whereIn() { + this._filter = this._filter.whereIn.apply(this._filter, arguments); - return this._or_where(field, OPERATORS[operator], value); + return this; } - public whereLike(field: string, value: any) { - if (!(value instanceof RegExp)) value = new RegExp(value, "i"); + public whereNotIn(field: string, values: any[]): this; + public whereNotIn() { + this._filter = this._filter.whereNotIn.apply(this._filter, arguments); - return this._where(field, "regex", value); + return this; } - public whereNotLike(field: string, value: any) { - if (!(value instanceof RegExp)) value = new RegExp(value, "i"); + public whereBetween(field: string, start: any, end: any): this; + public whereBetween() { + this._filter = this._filter.whereBetween.apply(this._filter, arguments); - return this._where(field, "not", value); + return this; } - public whereIn(field: string, values: any[]) { - return this._where(field, "in", values); - } + public whereNotBetween(field: string, start: any, end: any): this; + public whereNotBetween() { + this._filter = this._filter.whereNotBetween.apply(this._filter, arguments); - public whereNotIn(field: string, values: any[]) { - return this._where(field, "nin", values); + return this; } - public whereBetween(field: string, start: any, end: any) { - return this._where(field, "gte", start) - ._where(field, "lte", end); - } + public whereNull(field: string): this; + public whereNull() { + this._filter = this._filter.whereNull.apply(this._filter, arguments); - public whereNotBetween(field: string, start: any, end: any) { - return this._where(field, "lt", start) - ._or_where(field, "gt", end); + return this; } - public whereNull(field: string) { - return this._where(field, "eq", null); - } + public whereNotNull(field: string): this; + public whereNotNull() { + this._filter = this._filter.whereNotNull.apply(this._filter, arguments); - public whereNotNull(field: string) { - return this._where(field, "ne", null); + return this; } /*************** Mapping, Ordering, Grouping, Limit & Offset ****************/ @@ -429,7 +269,7 @@ class Driver extends Base { (query: any, mapper) => query.map(mapper), this._query.aggregate( [ - { $match: this._filter }, + { $match: this._filters }, ...this._pipeline, ], options @@ -437,16 +277,23 @@ class Driver extends Base { ) as mongodb.AggregationCursor; } + public exists(): Promise; + public exists(callback: mongodb.MongoCallback): void; public async exists(callback?: mongodb.MongoCallback) { if (callback) return this.count((err, res) => callback(err, res !== 0)); return (await this.count()) !== 0; } + public count(): Promise; + public count(callback: mongodb.MongoCallback): void; public count(callback?: mongodb.MongoCallback): Promise | void { - return this._query.countDocuments(this._filter, callback as any); + return this._query.countDocuments(this._filters, callback as any); } + public get(fields?: string[]): Promise; + public get(fields: string[], callback: mongodb.MongoCallback): void; + public get(callback: mongodb.MongoCallback): void; public get( fields?: string[] | mongodb.MongoCallback, callback?: mongodb.MongoCallback @@ -467,6 +314,9 @@ class Driver extends Base { .toArray(callback as any); } + public first(fields?: string[]): Promise; + public first(fields: string[], callback: mongodb.MongoCallback): void; + public first(callback: mongodb.MongoCallback): void; public async first( fields?: string[] | mongodb.MongoCallback, callback?: mongodb.MongoCallback @@ -493,6 +343,8 @@ class Driver extends Base { return (await this._aggregate().toArray())[0]; } + public value(field: string): Promise; + public value(field: string, callback: mongodb.MongoCallback): void; public value(field: string, callback?: mongodb.MongoCallback) { field = prepareKey(field); @@ -510,6 +362,8 @@ class Driver extends Base { .toArray(callback as any) as any; } + public max(field: string): Promise; + public max(field: string, callback: mongodb.MongoCallback): void; public async max(field: string, callback?: mongodb.MongoCallback) { const query = this.pipeline({ $group: { _id: null, max: { $max: `$${field}` } } }) ._aggregate(); @@ -524,6 +378,8 @@ class Driver extends Base { return (await query.toArray())[0].max; } + public min(field: string): Promise; + public min(field: string, callback: mongodb.MongoCallback): void; public async min(field: string, callback?: mongodb.MongoCallback) { const query = this.pipeline({ $group: { _id: null, min: { $min: `$${field}` } } }) ._aggregate(); @@ -538,6 +394,8 @@ class Driver extends Base { return (await query.toArray())[0].min; } + public avg(field: string): Promise; + public avg(field: string, callback: mongodb.MongoCallback): void; public async avg(field: string, callback?: mongodb.MongoCallback) { const query = this.pipeline({ $group: { _id: null, avg: { $avg: `$${field}` } } }) ._aggregate(); @@ -554,6 +412,8 @@ class Driver extends Base { /********************************** Inserts *********************************/ + public insert(item: T | T[]): Promise; + public insert(item: T | T[], callback: mongodb.MongoCallback): void; public async insert(item: T | T[], callback?: mongodb.MongoCallback) { item = this._prepareToStore(item); @@ -570,13 +430,15 @@ class Driver extends Base { return (await this._query.insertOne(item)).insertedCount; } - public async insertGetId(item: T, callback?: mongodb.MongoCallback) { + public insertGetId(item: T): Promise; + public insertGetId(item: T, callback: mongodb.MongoCallback): void; + public async insertGetId(item: T, callback?: mongodb.MongoCallback) { item = this._prepareToStore(item); if (callback) - return this._query.insertOne(item, (err, res) => callback(err, res.insertedId)); + return this._query.insertOne(item, (err, res) => callback(err, res.insertedId.toString())); - return (await this._query.insertOne(item)).insertedId; + return (await this._query.insertOne(item)).insertedId.toString(); } /********************************** Updates *********************************/ @@ -586,11 +448,13 @@ class Driver extends Base { callback?: mongodb.MongoCallback ): Promise { if (callback) - return this._query.updateMany(this._filter, update, callback) as any; + return this._query.updateMany(this._filters, update, callback) as any; - return await this._query.updateMany(this._filter, update); + return await this._query.updateMany(this._filters, update); } + public update(update: T): Promise; + public update(update: T, callback: mongodb.MongoCallback): void; public async update(update: T, callback?: mongodb.MongoCallback) { const _update = { $set: this._prepareToStore(update), @@ -602,6 +466,9 @@ class Driver extends Base { return (await this._update(_update)).modifiedCount; } + public increment(field: string, count?: number): Promise; + public increment(field: string, callback: mongodb.MongoCallback): void; + public increment(field: string, count: number, callback: mongodb.MongoCallback): void; public async increment( field: string, count?: number | mongodb.MongoCallback, @@ -627,14 +494,16 @@ class Driver extends Base { /********************************** Deletes *********************************/ + public delete(): Promise; + public delete(callback: mongodb.MongoCallback): void; public async delete(callback?: mongodb.MongoCallback) { if (callback) return this._query.deleteMany( - this._filter, + this._filters, (err, res: any) => callback(err, res.deletedCount) ); - return (await this._query.deleteMany(this._filter)).deletedCount; + return (await this._query.deleteMany(this._filters)).deletedCount; } } diff --git a/src/drivers/MongoDB/Relation/HasMany.ts b/src/drivers/MongoDB/Relation/HasMany.ts index 609b2ca..80460d9 100644 --- a/src/drivers/MongoDB/Relation/HasMany.ts +++ b/src/drivers/MongoDB/Relation/HasMany.ts @@ -5,7 +5,7 @@ class HasMany extends Base { public load(query: Query) { return query.join( this.relation, - q => q.on(this.foreignKey, `${this.model.constructor.toString()}.${this.localKey}`), + q => q.where(this.foreignKey, `${this.model.constructor.toString()}.${this.localKey}`), this.as ); } diff --git a/src/drivers/MongoDB/Relation/HasOne.ts b/src/drivers/MongoDB/Relation/HasOne.ts index cd95137..6e37344 100644 --- a/src/drivers/MongoDB/Relation/HasOne.ts +++ b/src/drivers/MongoDB/Relation/HasOne.ts @@ -8,7 +8,7 @@ class HasOne extends Base { return query.join( this.relation, - q => q.on(this.foreignKey, `${this.model.constructor.toString()}.${this.localKey}`), + q => q.where(this.foreignKey, `${this.model.constructor.toString()}.${this.localKey}`), as ).driver((q: Driver) => q.pipeline({ $unwind: { path: `$${as}`, preserveNullAndEmptyArrays: true }, diff --git a/src/drivers/MongoDB/Relation/MorphMany.ts b/src/drivers/MongoDB/Relation/MorphMany.ts index de6d2bf..a419217 100644 --- a/src/drivers/MongoDB/Relation/MorphMany.ts +++ b/src/drivers/MongoDB/Relation/MorphMany.ts @@ -7,8 +7,8 @@ class MorphMany extends Base { return query.join( this.relation, - q => q.on(this.foreignKey, `${this.model.constructor.toString()}.${this.localKey}`) - .on(`${this.type}_type`, this.model.constructor.name), + q => q.where(this.foreignKey, `${this.model.constructor.toString()}.${this.localKey}`) + .where(`${this.type}_type`, this.model.constructor.name), as ); } diff --git a/src/drivers/MongoDB/Relation/MorphOne.ts b/src/drivers/MongoDB/Relation/MorphOne.ts index e52eb34..0fa51b2 100644 --- a/src/drivers/MongoDB/Relation/MorphOne.ts +++ b/src/drivers/MongoDB/Relation/MorphOne.ts @@ -8,8 +8,8 @@ class MorphOne extends Base { return query.join( this.relation, - q => q.on(this.foreignKey, `${this.model.constructor.toString()}.${this.localKey}`) - .on(`${this.type}_type`, this.model.constructor.name), + q => q.where(this.foreignKey, `${this.model.constructor.toString()}.${this.localKey}`) + .where(`${this.type}_type`, this.model.constructor.name), as ).driver((q: Driver) => q.pipeline({ $unwind: { path: `$${as}`, preserveNullAndEmptyArrays: true }, diff --git a/src/drivers/MongoDB/utils.ts b/src/drivers/MongoDB/utils.ts new file mode 100644 index 0000000..3010a73 --- /dev/null +++ b/src/drivers/MongoDB/utils.ts @@ -0,0 +1,24 @@ +import { ObjectId } from "mongodb"; + +export const OPERATORS: { [operator: string]: string } = { + "<": "lt", + "<=": "lte", + "=": "eq", + "<>": "ne", + ">=": "gte", + ">": "gt", +}; + +export const isID = (id: string) => /(Id$|_id$|^id$)/.test(id); + +export const prepareKey = (id: string) => id === "id" ? "_id" : id; + +export const prepareValue = (field: string, value: any) => { + if (!isID(field)) return value; + + if (Array.isArray(value)) return value.map(v => new ObjectId(v)); + + if (!ObjectId.isValid(value)) return value; + + return new ObjectId(value); +}; diff --git a/src/types/Date.ts b/src/types/Date.ts index 6c1f434..fbe925a 100644 --- a/src/types/Date.ts +++ b/src/types/Date.ts @@ -10,12 +10,20 @@ class TypeDate extends TypeAny { return "Must be a date"; } - public min(d: Date) { - return this._test((v: Date) => v < d ? `Must be at least ${d}` : null); + public min(date: Date | (() => Date)) { + if (utils.date.isDate(date)) date = () => date as Date; + + return this._test((v: Date) => v < (date as (() => Date))() + ? `Must be at least ${date}` + : null); } - public max(d: Date) { - return this._test((v: Date) => v > d ? `Must be at most ${d}` : null); + public max(date: Date | (() => Date)) { + if (utils.date.isDate(date)) date = () => date as Date; + + return this._test((v: Date) => v > (date as (() => Date))() + ? `Must be at most ${date}` + : null); } } diff --git a/src/types/Object.ts b/src/types/Object.ts index 17b2d6a..3e14def 100644 --- a/src/types/Object.ts +++ b/src/types/Object.ts @@ -9,6 +9,8 @@ class TypeObject extends TypeAny { return "Must be a object"; } + + /********** TESTS **********/ } export default TypeObject; diff --git a/src/types/String.ts b/src/types/String.ts index 140b274..d010329 100644 --- a/src/types/String.ts +++ b/src/types/String.ts @@ -28,6 +28,11 @@ class TypeString extends TypeAny { ? `Must only contain a-z, A-Z, 0-9` : null); } + get numeral() { + return this._test((v: string) => !/^[0-9]*$/.test(v) + ? `Must only contain numbers` : null); + } + get ip() { return this._test((v: string) => !(ipv4Regex.test(v) || ipv6Regex.test(v)) ? `Must be an ipv4 or ipv6` : null); @@ -81,6 +86,18 @@ class TypeString extends TypeAny { return this._test((v: string) => !r.test(v) ? `Must match ${r}` : null); } + public enum(enums: string[]) { + enums.forEach((str) => { + if (!utils.string.isString(str)) throw new TypeError("'enums' must be an string array"); + }); + + const TYPE = JSON.stringify(enums); + + return this._test( + (v: string) => !utils.array.contains(enums, v) ? `Must be one of ${TYPE}` : null + ); + } + /********** CASTS **********/ public truncate(length: number) { diff --git a/src/types/index.ts b/src/types/index.ts index c97923f..e662b6d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +import { Driver } from "../connections"; import TypeArray from "./Array"; import TypeBoolean from "./Boolean"; import TypeDate from "./Date"; @@ -9,31 +10,33 @@ import TypeString from "./String"; declare module Type { } class Type { - static get Array() { + constructor(protected _driver: Driver) { } + + get Array() { return new TypeArray(); } - static get Boolean() { + get Boolean() { return new TypeBoolean(); } - static get Date() { + get Date() { return new TypeDate(); } - static get Number() { + get Number() { return new TypeNumber(); } - static get Object() { + get Object() { return new TypeObject(); } - static get ObjectId() { + get Id() { return new TypeObjectId(); } - static get String() { + get String() { return new TypeString(); } } diff --git a/test/Model.ts b/test/Model.ts index 22d75a7..9d4249c 100644 --- a/test/Model.ts +++ b/test/Model.ts @@ -31,15 +31,15 @@ const ITEMS = [ }, ]; -beforeAll((done) => { - Odin.connections({ - default: { - driver: "MongoDB", - database: global.__MONGO_DB_NAME__, - connection: global.__MONGO_CONNECTION__, - }, - }); +Odin.connections({ + default: { + driver: "MongoDB", + database: global.__MONGO_DB_NAME__, + connection: global.__MONGO_CONNECTION__, + }, +}); +beforeAll((done) => { Odin.DB.table(TABLE).insert(ITEMS, (err) => { if (err) throw err; @@ -77,11 +77,11 @@ afterAll((done) => { class User extends Odin { public static schema = { - username: Odin.Types.String.alphanum.min(3).required, - email: Odin.Types.String.email.required, + username: User.Types.String.alphanum.min(3).required, + email: User.Types.String.email.required, name: { - first: Odin.Types.String.min(3).required, - last: Odin.Types.String.min(3), + first: User.Types.String.min(3).required, + last: User.Types.String.min(3), }, }; } diff --git a/test/drivers/MongoDB.ts b/test/drivers/MongoDB.ts index 88b5cb5..62c93ff 100644 --- a/test/drivers/MongoDB.ts +++ b/test/drivers/MongoDB.ts @@ -422,7 +422,7 @@ describe("`MongoDB` driver", () => { expect(joinResult.length).toBe(JOIN_ITEMS.length); const result = await DB.table(TABLE).orderBy("num") - .join(JOIN_TABLE, q => q.on("for_name", `${TABLE}.name`)) + .join(JOIN_TABLE, q => q.where("for_name", `${TABLE}.name`)) .get(); expect(result).toEqual( diff --git a/tslint.json b/tslint.json index 6e6120a..5567b37 100644 --- a/tslint.json +++ b/tslint.json @@ -6,6 +6,7 @@ ], "jsRules": {}, "rules": { + "max-line-length": [true, 120], "quotemark": [ true, "double"