diff --git a/index.js b/index.js index 8f487088..053555ea 100644 --- a/index.js +++ b/index.js @@ -12,6 +12,7 @@ const { heresql } = require('./src/utils/string'); const Hint = require('./src/hint'); const Realm = require('./src/realm'); const Decorators = require('./src/decorators'); +const Raw = require('./src/raw'); const { MysqlDriver, PostgresDriver, SqliteDriver, AbstractDriver } = require('./src/drivers'); /** @@ -55,6 +56,7 @@ Object.assign(Realm, { PostgresDriver, SqliteDriver, AbstractDriver, + Raw, }); module.exports = Realm; diff --git a/package.json b/package.json index f853baaa..62452613 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "debug": "^3.1.0", + "deep-equal": "^2.0.5", "heredoc": "^1.3.1", "pluralize": "^7.0.0", "reflect-metadata": "^0.1.13", diff --git a/src/bone.js b/src/bone.js index c4000a65..0d796d85 100644 --- a/src/bone.js +++ b/src/bone.js @@ -5,6 +5,7 @@ * @module */ const util = require('util'); +const deepEqual = require('deep-equal'); const pluralize = require('pluralize'); const { executeValidator, LeoricValidateError } = require('./validator'); require('reflect-metadata'); @@ -380,7 +381,7 @@ class Bone { if (this.#rawUnset.has(name) || !this.hasAttribute(name)) return false; const value = this.attribute(name); const valueWas = this.attributeWas(name); - return !util.isDeepStrictEqual(value, valueWas); + return !deepEqual(value, valueWas); } /** @@ -412,7 +413,7 @@ class Bone { if (this.#rawUnset.has(name) || this.#rawPrevious[name] === undefined || !this.hasAttribute(name)) return {}; const value = this.attribute(name); const valueWas = this.#rawPrevious[name] == null ? null : this.#rawPrevious[name]; - if (util.isDeepStrictEqual(value, valueWas)) return {}; + if (deepEqual(value, valueWas)) return {}; return { [name]: [ valueWas, value ] }; } const result = {}; @@ -420,7 +421,7 @@ class Bone { if (this.#rawUnset.has(attrKey) || this.#rawPrevious[attrKey] === undefined) continue; const value = this.attribute(attrKey); const valueWas = this.#rawPrevious[attrKey] == null ? null : this.#rawPrevious[attrKey]; - if (!util.isDeepStrictEqual(value, valueWas)) result[attrKey] = [ valueWas, value ]; + if (!deepEqual(value, valueWas)) result[attrKey] = [ valueWas, value ]; } return result; } @@ -439,7 +440,7 @@ class Bone { if (this.#rawUnset.has(name) || !this.hasAttribute(name)) return {}; const value = this.attribute(name); const valueWas = this.attributeWas(name); - if (util.isDeepStrictEqual(value, valueWas)) return {}; + if (deepEqual(value, valueWas)) return {}; return { [name]: [ valueWas, value ] }; } const result = {}; @@ -448,7 +449,7 @@ class Bone { const value = this.attribute(attrKey); const valueWas = this.attributeWas(attrKey); - if (!util.isDeepStrictEqual(value, valueWas)) { + if (!deepEqual(value, valueWas)) { result[attrKey] = [ valueWas, value ]; } } diff --git a/src/hint.js b/src/hint.js index a0a1f4d2..db317b33 100644 --- a/src/hint.js +++ b/src/hint.js @@ -1,6 +1,7 @@ 'use strict'; -const { isDeepStrictEqual, format } = require('util'); +const { format } = require('util'); +const isDeepStrictEqual = require('deep-equal'); const { isPlainObject } = require('./utils'); /** diff --git a/test/types/custom_driver.ts b/test/types/custom_driver.ts new file mode 100644 index 00000000..a2e8017d --- /dev/null +++ b/test/types/custom_driver.ts @@ -0,0 +1,248 @@ +import { strict as assert } from 'assert'; +const SqlString = require('sqlstring'); + +import Realm, { SqliteDriver, SpellMeta, Literal, SpellBookFormatResult } from '../..'; +const { formatConditions, collectLiteral } = require('../../src/expr_formatter'); +const { findExpr } = require('../../src/expr'); +const Raw = require('../../src/raw'); + +interface FormatResult { + table?: string; + whereArgs?: Array + whereClause?: string, + values?: Array | { + [key: string]: Literal + }; + [key: string]: Literal +} + +class MySpellbook extends SqliteDriver.Spellbook { + + format(spell: SpellMeta): SpellBookFormatResult { + for (const scope of spell.scopes) scope(spell); + switch (spell.command) { + case 'insert': + return this.formatMyInsert(spell); + case 'bulkInsert': + return this.formatInsert(spell); + case 'select': + return this.formatSelect(spell); + case 'update': + return this.formatUpdate(spell); + case 'delete': + return this.formatDelete(spell); + case 'upsert': + return this.formatUpsert(spell); + default: + throw new Error(`Unsupported SQL command ${spell.command}`); + } + } + + formatUpdate(spell: SpellMeta): SpellBookFormatResult { + const a = super.formatDelete(spell); + + const { Model, sets, whereConditions } = spell; + const { shardingKey } = Model; + const { escapeId } = Model.driver; + if (shardingKey) { + if (sets.hasOwnProperty(shardingKey) && sets[shardingKey] == null) { + throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL`); + } + if (!whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) { + throw new Error(`Sharding key ${Model.table}.${shardingKey} is required.`); + } + } + + if (Object.keys(sets).length === 0) { + throw new Error('Unable to update with empty set'); + } + + const table = escapeId(spell.table.value); + const opValues = {}; + Object.keys(spell.sets).reduce((obj, key) => { + obj[escapeId(Model.unalias(key))] = spell.sets[key]; + return obj; + }, opValues); + + let whereArgs = []; + let whereClause = ''; + if (whereConditions.length > 0) { + for (const condition of whereConditions) collectLiteral(spell, condition, whereArgs); + whereClause += `WHERE ${formatConditions(spell, whereConditions)}`; + } + return { + table, + whereArgs, + whereClause, + opValues, + }; + } + + formatDelete(spell: SpellMeta): SpellBookFormatResult { + const { Model, whereConditions } = spell; + const { escapeId } = Model.driver; + const table = escapeId(spell.table.value); + let whereArgs = []; + let whereClause = ''; + if (whereConditions.length > 0) { + for (const condition of whereConditions) collectLiteral(spell, condition, whereArgs); + whereClause += `WHERE ${formatConditions(spell, whereConditions)}`; + } + return { + table, + whereArgs, + whereClause, + }; + } + + formatMyInsert(spell: SpellMeta): SpellBookFormatResult { + const { Model, sets } = spell; + const { escapeId } = Model.driver; + const table = escapeId(spell.table.value); + let values = {}; + + const { shardingKey } = Model; + if (shardingKey && sets[shardingKey] == null) { + throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL.`); + } + for (const name in sets) { + const value = sets[name]; + values[escapeId(Model.unalias(name))] = value instanceof Raw? SqlString.raw(value.value) : value; + } + + return { + table, + values, + }; + } + +}; + +class CustomDriver extends SqliteDriver { + static Spellbook = MySpellbook; + + async cast(spell) { + const { command } = spell; + switch (command) { + case 'update': { + const updateParams = this.format(spell); + return await this.update(updateParams, spell); + } + case 'delete': { + const deleteParams = this.format(spell); + return await this.delete(deleteParams, spell); + } + case 'insert': { + const insertParams = this.format(spell); + return await this.insert(insertParams, spell); + } + case 'upsert': + case 'bulkInsert': + case 'select': { + const { sql, values } = this.format(spell); + const query = { sql, nestTables: command === 'select' }; + return await this.query(query, values, spell); + } + default: + throw new Error('unspported sql command'); + } + } + + async update({ table, values, whereClause, whereArgs }, options?: SpellMeta) { + const valueSets = []; + const assignValues = []; + Object.keys(values).map((key) => { + valueSets.push(`${key}=?`); + assignValues.push(values[key]); + }); + const sql = `UPDATE ${table} SET ${valueSets.join(',')} ${whereClause}`; + return await this.query(sql, assignValues.concat(whereArgs), options); + } + + async delete({ table, whereClause, whereArgs }, options) { + const sql = `DELETE FROM ${table} ${whereClause}`; + return await this.query(sql, whereArgs, options); + } + + async insert({ table, values }: { table: string, values: {[key: string]: Literal}}, options?: SpellMeta) { + const valueSets = []; + const assignValues = []; + Object.keys(values).map((key) => { + valueSets.push(key); + assignValues.push(values[key]); + }); + const sql = `INSERT INTO ${table} (${valueSets.join(',')}) VALUES (${valueSets.map(_ => '?')})`; + return await this.query(sql, assignValues, options); + } +}; + +describe('=> Realm (TypeScript)', function () { + let realm: Realm; + before(function() { + realm = new Realm({ + driver: CustomDriver, + database: '/tmp/leoric.sqlite3', + subclass: true, + }); + }); + + describe('realm.define(name, attributes, options, descriptors)', async function() { + it('options and descriptors should be optional', async function() { + assert.doesNotThrow(function() { + const { STRING } = realm.DataTypes; + realm.define('User', { name: STRING }); + }); + }); + + it('can customize attributes with descriptors', async function() { + const { STRING } = realm.DataTypes; + const User = realm.define('User', { name: STRING }, {}, { + get name() { + return this.attribute('name').replace(/^([a-z])/, function(m, chr) { + return chr.toUpperCase(); + }); + }, + set name(value) { + if (typeof value !== 'string') throw new Error('unexpected name' + value); + this.attribute('name', value); + } + }); + // User.findOne should exists + assert(User.findOne); + }); + }); + + describe('realm.sync(options)', async function() { + it('options should be optional', async function() { + assert.doesNotThrow(async () => { + const { STRING } = realm.DataTypes; + realm.define('User', { name: STRING }); + await realm.sync(); + }); + }); + + it('`force` can be passed individually', async function() { + assert.doesNotThrow(async () => { + const { STRING } = realm.DataTypes; + realm.define('User', { name: STRING }); + await realm.sync({ force: true }); + }); + }); + + it('`alter` can be passed individually', async function() { + assert.doesNotThrow(async () => { + const { STRING } = realm.DataTypes; + realm.define('User', { name: STRING }); + await realm.sync({ alter: true }); + }); + }); + + it('`force` and `alter` can be passed together', async function() { + assert.doesNotThrow(async () => { + const { STRING } = realm.DataTypes; + realm.define('User', { name: STRING }); + await realm.sync({ force: true, alter: true }); + }); + }); + }); +}); diff --git a/types/hint.d.ts b/types/hint.d.ts new file mode 100644 index 00000000..4ee13a52 --- /dev/null +++ b/types/hint.d.ts @@ -0,0 +1,96 @@ +export class Hint { + static build(hint: Hint | { index: string } | string): typeof Hint; + + constructor(text: string); + + set text(value: string); + + get text(): string; + + /** + * + * @param {Hint} hint + * @returns {boolean} + * @memberof Hint + */ + isEqual(hint: Hint): boolean; + + toSqlString(): string; +} + +/** + * @enum + */ +export enum INDEX_HINT_TYPE { + use = 'use', + force = 'force', + ignore = 'ignore' +} + +/** + * @enum + */ +export enum INDEX_HINT_SCOPE { + join = 'join', + orderBy = 'order by', + groupBy = 'group by', +} + +export class IndexHint { + /** + * build index hint + * + * @static + * @param {object | string} obj + * @param {string} indexHintType + * @returns {IndexHint} + * @example + * build('idx_title') + * build('idx_title', INDEX_HINT_TYPE.force, INDEX_HINT_SCOPE.groupBy) + * build({ + * index: 'idx_title', + * type: INDEX_HINT_TYPE.ignore, + * scope: INDEX_HINT_SCOPE.groupBy, + * }) + */ + static build(hint: string | Array | { index: string, type?: INDEX_HINT_TYPE, scope?: INDEX_HINT_SCOPE }, type?: INDEX_HINT_TYPE, scope?: INDEX_HINT_SCOPE): IndexHint; + + /** + * Creates an instance of IndexHint. + * @param {Array | string} index + * @param {INDEX_HINT_TYPE} type + * @param {INDEX_HINT_SCOPE?} scope + * @memberof IndexHint + */ + constructor(index: string, type?: INDEX_HINT_TYPE, scope?: INDEX_HINT_SCOPE); + + set index(values: string | Array); + + get index(): Array; + + set type(value: string); + + get type(): string; + + set scope(value: string); + + get scope(): string; + + toSqlString(): string; + + /** + * + * @param {IndexHint} hint + * @returns {boolean} + * @memberof IndexHint + */ + isEqual(hint: IndexHint): boolean; + + /** + * @static + * @param {IndexHint} hints + * @returns {Array} + * @memberof IndexHint + */ + static merge(hints: Array): Array; +} diff --git a/types/index.d.ts b/types/index.d.ts index ff241ad1..2d81bea0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,8 +1,18 @@ import DataType from './data_types'; +import { Hint, IndexHint } from './hint'; export { DataType as DataTypes }; export * from '../src/decorators'; +export type command = 'select' | 'insert' | 'bulkInsert' | 'update' | 'delete' | 'upsert'; +export type Literal = null | undefined | boolean | number | bigint | string | Date | object | ArrayBuffer; + +export class Raw { + value: string; + type: 'raw'; +} + + type DataTypes = { [Property in keyof T as Exclude]: T[Property] } @@ -37,20 +47,35 @@ interface ExprTernaryOperator { } type ExprOperator = ExprBinaryOperator | ExprTernaryOperator; +type SpellColumn = ExprIdentifier | Raw; + +interface Join { + [key: string]: { + Model: typeof Bone; + on: ExprBinaryOperator + } +} interface SpellOptions { - command?: string; - columns: Object[]; + command?: command; + columns: SpellColumn[]; table: ExprIdentifier; whereConditions: ExprOperator[]; groups: (ExprIdentifier | ExprFunc)[]; orders: (ExprIdentifier | ExprFunc)[]; havingCondtions: ExprOperator[]; - joins: Object; + joins: Join; skip: number; scopes: Function[]; subqueryIndex: number; - rowCount: 0; + rowCount?: number; + connection?: Connection; + sets?: { [key: string]: Literal } | { [key: string]: Literal }[]; + hints?: Array; +} + +export interface SpellMeta extends SpellOptions { + Model: typeof Bone; } type OrderOptions = { [name: string]: 'desc' | 'asc' }; @@ -64,7 +89,10 @@ type WithOptions = { export class Spell | Collection> | ResultSet | number | null> extends Promise { constructor(Model: T, opts: SpellOptions); - select(...names: Array | Array<(name: string) => boolean>): Spell; + command: string; + scopes: Function[]; + + select(...names: Array | Array<(name: string) => boolean>): Spell; insert(opts: SetOptions): Spell; update(opts: SetOptions): Spell; upsert(opts: SetOptions): Spell; @@ -86,7 +114,7 @@ export class Spell | Collection): Spell; orWhere(conditions: string, ...values: Literal[]): Spell; - group(...names: Array): Spell; + group(...names: Array): Spell; having(conditions: string, ...values: Literal[]): Spell; having(conditions: WhereConditions): Spell; @@ -115,7 +143,6 @@ export class Spell | Collection = { [Property in keyof Extract]?: Extract[Property] } -export interface AttributeMeta { +export interface ColumnMeta { columnName?: string; columnType?: string; allowNull?: boolean; defaultValue?: Literal; primaryKey?: boolean; + unique?: boolean; dataType?: string; + comment?: string; + datetimePrecision?: string; +} +export interface AttributeMeta extends ColumnMeta { jsType?: Literal; type: DataType; + virtual?: boolean, toSqlString: () => string; } @@ -187,7 +220,59 @@ declare class Pool { getConnection(): Connection; } -declare class Driver { +declare class Attribute { + /** + * attribute name + */ + name: string; + /** + * primaryKey tag + */ + primaryKey: boolean; + allowNull: boolean; + /** + * attribute column name in table + */ + columnName: string; + columnType: string; + type: typeof DataType; + defaultValue: Literal; + dataType: string; + jsType: Literal; + virtual: boolean; + + euals(columnInfo: ColumnMeta): boolean; + cast(value: Literal): Literal; + uncast(value: Literal): Literal; +} + +interface SpellBookFormatStandardResult { + sql?: string; + values?: Array | { + [key: string]: Literal + }; + [key: string]: Literal +} + +export type SpellBookFormatResult = SpellBookFormatStandardResult | T; + +declare class Spellbook { + + format(spell: SpellMeta): SpellBookFormatResult; + + formatInsert(spell: SpellMeta): SpellBookFormatResult; + formatSelect(spell: SpellMeta): SpellBookFormatResult; + formatUpdate(spell: SpellMeta): SpellBookFormatResult; + formatDelete(spell: SpellMeta): SpellBookFormatResult; + formatUpsert(spell: SpellMeta): SpellBookFormatResult; +} + +declare class AbstractDriver { + + static Spellbook: typeof Spellbook; + static DataType: typeof DataType; + static Attribute: typeof Attribute; + /** * The type of driver, currently there are mysql, sqlite, and postgres */ @@ -203,10 +288,147 @@ declare class Driver { */ pool: Pool; + /** + * The SQL dialect + */ + dialect: string; + + spellbook: Spellbook; + + DataType: DataType; + + Attribute: Attribute; + + constructor(options: ConnectOptions); + + escape: (v: string) => string; + escapeId: (v: string) => string; + /** * Grab a connection and query the database */ - query(sql: string, values?: Array): Promise; + query(sql: string | { sql: string, nestTables?: boolean}, values?: Array, opts?: SpellMeta): Promise; + + /** + * query with spell + * @param spell + */ + cast(spell: Spell): Promise; + + /** + * format spell + * @param spell SpellMeta + */ + format(spell: SpellMeta): any; + + /** + * create table + * @param tabe table name + * @param attributes attributes + */ + createTable(tabe: string, attributes: { [key: string]: DataTypes | AttributeMeta }): Promise; + + /** + * alter table + * @param tabe table name + * @param attributes alter attributes + */ + alterTable(tabe: string, attributes: { [key: string]: DataTypes | AttributeMeta }): Promise; + + /** + * describe table + * @param table table name + */ + describeTable(table: string): Promise<{ [key: string]: ColumnMeta }>; + + /** + * query table schemas + * @param database database name + * @param table table name or table name array + */ + querySchemaInfo(database: string, table: string | string[]): Promise<{ [key: string] : { [key: string]: ColumnMeta }[]}>; + + /** + * add column to table + * @param table table name + * @param name column name + * @param params column meta info + */ + addColumn(table: string, name: string, params: ColumnMeta): Promise; + + /** + * change column meta in table + * @param table table name + * @param name column name + * @param params column meta info + */ + changeColumn(table: string, name: string, params: ColumnMeta): Promise; + + /** + * remove column in table + * @param table table name + * @param name column name + */ + removeColumn(table: string, name: string): Promise; + + /** + * rename column in table + * @param table table name + * @param name column name + * @param newName new column name + */ + renameColumn(table: string, name: string, newName: string): Promise; + + /** + * rename table + * @param table table name + * @param newTable new table name + */ + renameTable(table: string, newTable: string): Promise; + + /** + * drop table + * @param table table name + */ + dropTable(table: string): Promise; + + /** + * truncate table + * @param table table name + */ + truncateTable(table: string): Promise; + + /** + * add index in table + * @param table table name + * @param attributes attributes name + * @param opts + */ + addIndex(table: string, attributes: string[], opts?: { unique?: boolean, type?: string }): Promise; + + /** + * remove index in table + * @param table string + * @param attributes attributes name + * @param opts + */ + removeIndex(table: string, attributes: string[], opts?: { unique?: boolean, type?: string }): Promise; + +} + +export class MysqlDriver extends AbstractDriver { + type: 'mysql'; + dialect: 'mysql'; +} + +export class PostgresDriver extends AbstractDriver { + type: 'postgres'; + dialect: 'postgres'; +} + +export class SqliteDriver extends AbstractDriver { + type: 'sqlite'; + dialect: 'sqlite'; } type ResultSet = { @@ -230,7 +452,7 @@ export class Bone { /** * The driver that powers the model */ - static driver: Driver; + static driver: AbstractDriver; /** * The connected models structured as `{ [model.name]: model }`, e.g. `Bone.model.Post => Post` @@ -303,6 +525,7 @@ export class Bone { static alias(name: string): string; static alias(data: Record): Record; + static unalias(name: string): string; static hasOne(name: string, opts?: RelateOptions): void; static hasMany(name: string, opts?: RelateOptions): void; @@ -474,7 +697,7 @@ export class Bone { static initialize(): void; - constructor(values: { [key: string]: Literal }); + constructor(values: { [key: string]: Literal }, opts?: { isNewRecord?: boolean }); /** * @example @@ -609,6 +832,7 @@ export interface ConnectOptions { charset?: string; models?: string | (typeof Bone)[]; subclass?: boolean; + driver?: typeof AbstractDriver; } interface InitOptions { @@ -626,12 +850,6 @@ interface SyncOptions { alter?: boolean; } -type RawSql = { - __raw: true, - value: string, - type: 'raw', -}; - interface RawQueryOptions { replacements?: { [key:string]: Literal | Literal[] }; model: Bone; @@ -641,11 +859,14 @@ interface RawQueryOptions { export default class Realm { Bone: typeof Bone; DataTypes: typeof DataType; - driver: Driver; + driver: AbstractDriver; models: Record; + connected?: boolean; constructor(options: ConnectOptions); + connect(): Promise; + define( name: string, attributes: Record | AttributeMeta>, @@ -653,7 +874,7 @@ export default class Realm { descriptors?: Record, ): typeof Bone; - raw(sql: string): RawSql; + raw(sql: string): Raw; escape(value: Literal): string; @@ -676,3 +897,8 @@ export default class Realm { * }) */ export function connect(opts: ConnectOptions): Promise; + +export { + Hint, + IndexHint, +}