diff --git a/.jshintrc b/.jshintrc index 66670a0..f9aa219 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1,23 +1,23 @@ { -"node": true, -"browser": true, -"camelcase" : true, -"eqnull" : true, -"indent": 2, -"undef": true, -"unused": true, -"quotmark": "single", -"maxlen": 90, -"trailing": true, -"newcap": true, -"nonew": true, -"sub": true, -"globals": { - "describe": true, - "it": true, - "before": true, - "beforeEach": true, - "after": true, - "afterEach": true -} + "node": true, + "browser": true, + "camelcase": true, + "eqnull": true, + "indent": 2, + "undef": true, + "unused": "vars", + "quotmark": "true", + "maxlen": 110, + "trailing": true, + "newcap": true, + "nonew": true, + "sub": true, + "globals": { + "describe": true, + "it": true, + "before": true, + "beforeEach": true, + "after": true, + "afterEach": true + } } diff --git a/docs.json b/docs.json new file mode 100644 index 0000000..3ed5576 --- /dev/null +++ b/docs.json @@ -0,0 +1,22 @@ +{ + "content": [ + { + "title": "Build a SQL connector", + "depth": 2 + }, + "docs/sql-connector.md", + { + "title": "Base Connector", + "depth": 2 + }, + "lib/connector.js", + { + "title": "SQL Connector", + "depth": 2 + }, + "lib/sql.js", + "lib/parameterized-sql.js" + ], + "codeSectionDepth": 3 +} + diff --git a/docs/connector-architecture.png b/docs/connector-architecture.png new file mode 100644 index 0000000..3709668 Binary files /dev/null and b/docs/connector-architecture.png differ diff --git a/docs/crud-connector.png b/docs/crud-connector.png new file mode 100644 index 0000000..d1e8581 Binary files /dev/null and b/docs/crud-connector.png differ diff --git a/docs/sql-connector.md b/docs/sql-connector.md new file mode 100644 index 0000000..ede3dac --- /dev/null +++ b/docs/sql-connector.md @@ -0,0 +1,662 @@ +# Build a connector for relational databases + +This tutorial walks you through the MySQL connector implementation to teach you +how to develop a connector for relational databases. + +## Understand a connector's responsibilities + +In LoopBack, models encapsulate business data and logic as JavaScript properties +and methods. One of the powerful features of LoopBack is that application +developers don't have to implement all behaviors for their models as a lot of +them are already provided by the framework out of box with data sources and +connectors. For example, a model automatically receives the create, retrieve, +update, and delete (CRUD) functions if it is attached to a data source for a +database. LoopBack abstracts the persistence layer and other backend services, +such as REST APIs, SOAP web services, and storage services, and so on, as data +sources, which are configurations of backend connectivity and integration. +Each data source is backed a connector which implements the interactions between +Node.js and its underlying backend system. Connectors are responsible for +mapping model method invocations to backend functions, such as database +operations or call to REST or SOAP APIs. The following diagram illustrates how +connectors fit into the big picture of LoopBack API framework. + +![connector-architecture](connector-architecture.png) + +Please note that you don't always have to develop a connector to allow your +application to interact with other systems. Ad-hoc integration can be done with +custom methods on the model. The custom methods can be implemented using other +Node modules, such as drivers or clients to your backend. + +You should consider to develop a connector for common and reusable backend +integrations, for example: + +- Integrate with a backend such as databases +- Reusable logic to interact with another system + +There are a few typical types of connectors based on what backends they connect +to and interact with. + +- Databases that support full CRUD operations + - Oracle, SQL Server, MySQL, Postgresql, MongoDB, In-memory DB +- Other forms of existing APIs + - REST APIs exposed by your backend + - SOAP/HTTP web services +- Services + - E-mail + - Push notification + - Storage + +The connectors are mostly transparent to models. Their functions are mixed into +model classes through data source attachments. + +Most of the connectors need to implement the following logic: + +- Lifecycle handlers + - initialize: receive configuration from the data source settings and + initialize the connector instance + - connect: create connections to the backend system + - disconnect: close connections to the backend system + - ping (optional): check if the connectivity + +- Model method delegations + - Delegating model method invocations to backend calls, for example CRUD + +- Connector metadata (optional) + - Model definition for the configuration, such as host/url/user/password + - What data access interfaces are implemented by the connector (the capability + of the connector) + - Connector-specific model/property mappings + +To mixin methods onto model classes, a connector must choose what functions to +offer. Different types of connectors implement different interfaces that group +a set of common methods, for example: + +- Database connectors + - CRUD methods, such as create, find, findById, deleteAll, updateAll, count + +- E-mail connector + - send() + +- Storage connector + - Container/File operations, such as createContainer, getContainers, getFiles, + upload, download, deleteFile, deleteContainer + +- Push Notification connector + - notify() + +- REST connector + - Map operations from existing REST APIs + +- SOAP connector + - Map WSDL operations + +In this tutorial, we'll focus on building a connector for databases that provide +the full CRUD capabilities. + +## Understand a database connector with CRUD operations + +![crud-connector](crud-connector.png) + +LoopBack unifies all CRUD based database connectors so that a model can choose +to attach to any of the supported database. There are a few classes involved +here: + +1. [PersistedModelClass](http://docs.strongloop.com/display/public/LB/PersistedModel+class) +defines all the methods mixed into a model for persistence. + +2. [The DAO facade](https://github.com/strongloop/loopback-datasource-juggler/blob/master/lib/dao.js) +maps the PersistedModel methods to connector implementations. + +3. CRUD methods need to be implemented by connectors + + + +In the next sections, we will use MySQL connector as an example to walk through +how to implement a SQL based connector. + +## Define a module and export the *initialize* function + +A LoopBack connector is packaged as a Node.js module that can be installed using +`npm install`. LoopBack runtime loads the module via `require` on behalf of +data source configuration, for example, `require('loopback-connector-mysql');`. +The connector module should export an `initialize` function as follows: + +```js +// Require the DB driver +var mysql = require('mysql'); +// Require the base SqlConnector class +var SqlConnector = require('loopback-connector').SqlConnector; +// Require the debug module with a pattern of loopback:connector:connectorName +var debug = require('debug')('loopback:connector:mysql'); + +/** + * Initialize the MySQL connector against the given data source + * + * @param {DataSource} dataSource The loopback-datasource-juggler dataSource + * @param {Function} [callback] The callback function + */ +exports.initialize = function initializeDataSource(dataSource, callback) { + ... +}; +``` + +After the initialization, the dataSource object will have the following properties +added: + +- connector: The connector instance +- driver: The module for the underlying database driver (`mysql` for MySQL) + +The `initialize` function should calls the `callback` function once the connector +has been initialized. + +## Create a subclass of SqlConnector + +Connectors for relational databases have a lot of things in common. They are +responsible for mapping CRUD operations to SQL statements. LoopBack provides a +base class called `SqlConnector` that encapsulates the common logic for inheritance. +The following code snippet is used to create a subclass of SqlConnector. + +```js +/** + * @constructor + * Constructor for MySQL connector + * @param {Object} settings The data source settings + */ +function MySQL(settings) { + // Call the super constructor with name and settings + SqlConnector.call(this, 'mysql', settings); + ... +} +// Set up the prototype inheritence +require('util').inherits(MySQL, SqlConnector); +``` + +## Implement methods to interact with the database + +A connector implements the following methods to communicate with the underlying +database. + +### Connect to the database + +The `connect` method establishes connections to the database. In most cases, a +connection pool will be created based on the data source settings, including +`host`, `port`, `database`, and other configuration properties. + +```js +MySQL.prototype.connect = function (cb) { + // ... +}; +``` + +### Disconnect from the database + +The `disconnect` method close connections to the database. Most database drivers +provide APIs. + +```js +/** + * Disconnect from MySQL + */ +MySQL.prototype.disconnect = function (cb) { + // ... +}; +``` +### Ping the database + +The `ping` method tests if the connection to the database is healthy. Most +connectors choose to implement it by executing a simple SQL statement. + +```js +MySQL.prototype.ping = function(cb) { + // ... +}; +``` + +## Implement CRUD methods + +The connector is responsible for implementing the following CRUD methods. The +good news is that the base SqlConnector now have most of the methods implemented +with the extension point to override certain behaviors that are specific to the +underlying database. + +To extend from SqlConnector, the minimum set of methods below must be +implemented: + + +### Execute a SQL statement with parameters + +The `executeSQL` method is the core function that a connector has to implement. +Most of other CRUD methods are delegated to the `query` function. It executes +a SQL statement with an array of parameters. `SELECT` statements will produce +an array of records representing matching rows from the database while other +statements such as `INSERT`, `DELETE`, or `UPDATE` will report the number of +rows changed during the operation. + +```js +/** + * Execute the parameterized sql statement + * + * @param {String} sql The SQL statement, possibly with placeholders for parameters + * @param {*[]} [params] An array of parameter values + * @param {Objet} [options] Options passed to the CRUD method + * @param {Function} [callback] The callback after the SQL statement is executed + */ +MySQL.prototype.executeSQL = function (sql, params, options, callback) { + // ... +}; +``` + +### Map values between a model property and a database column + +```js +/** + * Converts a model property value into the form required by the + * database column. The result should be one of following forms: + * + * - {sql: "point(?,?)", params:[10,20]} + * - {sql: "'John'", params: []} + * - "John" + * + * @param {Object} propertyDef Model property definition + * @param {*} value Model property value + * @returns {ParameterizedSQL|*} Database column value. + * + */ +SqlConnector.prototype.toColumnValue = function(propertyDef, value) { + /*jshint unused:false */ + throw new Error('toColumnValue() must be implemented by the connector'); +}; + +/** + * Convert the data from database column to model property + * @param {object} propertyDef Model property definition + * @param {*) value Column value + * @returns {*} Model property value + */ +SqlConnector.prototype.fromColumnValue = function(propertyDef, value) { + /*jshint unused:false */ + throw new Error('fromColumnValue() must be implemented by the connector'); +}; +``` + +### Helpers to generate SQL statements and parse responses from DB drivers + +```js +/** + * Build a new SQL statement with pagination support by wrapping the given sql + * @param {String} model The model name + * @param {ParameterizedSQL} stmt The sql statement + * @param {Number} limit The maximum number of records to be fetched + * @param {Number} offset The offset to start fetching records + * @param {String[]} order The sorting criteria + */ +SqlConnector.prototype.applyPagination = function(model, stmt, filter) { + /*jshint unused:false */ + throw new Error('applyPagination() must be implemented by the connector'); +}; + +/** + * Parse the result for SQL UPDATE/DELETE/INSERT for the number of rows + * affected + * @param {String} model Model name + * @param {Object} info Status object + * @returns {Number} Number of rows affected + */ +SqlConnector.prototype.getCountForAffectedRows = function(model, info) { + /*jshint unused:false */ + throw new Error('getCountForAffectedRows() must be implemented by the connector'); +}; + +/** + * Parse the result for SQL INSERT for newly inserted id + * @param {String} model Model name + * @param {Object} info The status object from driver + * @returns {*} The inserted id value + */ +SqlConnector.prototype.getInsertedId = function(model, info) { + /*jshint unused:false */ + throw new Error('getInsertedId() must be implemented by the connector'); +}; + +/** + * Escape the name for the underlying database + * @param {String} name The name + * @returns {String} An escaped name for SQL + */ +SqlConnector.prototype.escapeName = function(name) { + /*jshint unused:false */ + throw new Error('escapeName() must be implemented by the connector'); +}; + +/** + * Escape the name for the underlying database + * @param {String} value The value to be escaped + * @returns {*} An escaped value for SQL + */ +SqlConnector.prototype.escapeValue = function(value) { + /*jshint unused:false */ + throw new Error('escapeValue() must be implemented by the connector'); +}; + +/** + * Get the place holder in SQL for identifiers, such as ?? + * @param {String} key Optional key, such as 1 or id + * @returns {String} The place holder + */ +SqlConnector.prototype.getPlaceholderForIdentifier = function(key) { + /*jshint unused:false */ + throw new Error('getPlaceholderForIdentifier() must be implemented by the connector'); +}; + +/** + * Get the place holder in SQL for values, such as :1 or ? + * @param {String} key Optional key, such as 1 or id + * @returns {String} The place holder + */ +SqlConnector.prototype.getPlaceholderForValue = function(key) { + /*jshint unused:false */ + throw new Error('getPlaceholderForValue() must be implemented by the connector'); +}; +``` + +### Override other methods + +There are a list of methods that serve as default implementations in the SqlConnector. +The connector can choose to override such methods to customize the behaviors. Please +see a complete list at http://apidocs.strongloop.com/loopback-connector/. + +## Implement database/model synchronization methods + +It's often desirable to apply model definitions to the underlying relational +database to provision or update schema objects so that they stay synchronized +with the model definitions. + +### automigrate and autoupdate + +There are two flavors: + +- automigrate - Drop existing schema objects if exist and create them based on +model definitions. Existing data will be lost. +- autoupdate - Detects the difference between schema objects and model +definitions, alters the database schema objects. Existing data will be kept. + +```js +/** + * Perform autoupdate for the given models + * @param {String[]} [models] A model name or an array of model names. + * If not present, apply to all models + * @param {Function} [cb] The callback function + */ +MySQL.prototype.autoupdate = function (models, cb) { + // ... +}; + +MySQL.prototype.automigrate = function (models, cb) { + // ... +}; +``` + +The `automigrate` and `autoupdate` operations are usually mapped to a sequence of +DDL statements. + +### Build a CREATE TABLE statement + +```js +/** + * Create a DB table for the given model + * @param {string} model Model name + * @param cb + */ +MySQL.prototype.createTable = function (model, cb) { + // ... +}; +``` + +### Check if models have corresponding tables + +```js +/** + * Check if the models exist + * @param {String[]} [models] A model name or an array of model names. If not + * present, apply to all models + * @param {Function} [cb] The callback function + */ +MySQL.prototype.isActual = function(models, cb) { + // ... +}; +``` + +### Alter a table + +```js +MySQL.prototype.alterTable = function (model, actualFields, actualIndexes, done, checkOnly) { + // ... +}; +``` + +### Build column definition clause for a given model + +```js +MySQL.prototype.buildColumnDefinitions = +MySQL.prototype.propertiesSQL = function (model) { + // ... +}; +``` + +### Build index definition clause for a given model property + +```js +MySQL.prototype.buildIndex = function(model, property) { + // ... +}; +``` + +### Build indexes for a given model + +```js +MySQL.prototype.buildIndexes = function(model) { + // ... +}; +``` + +### Build column definition for a given model property + +```js +MySQL.prototype.buildColumnDefinition = function(model, prop) { + // ... +}; +``` + +### Build column type for a given model property + +```js +MySQL.prototype.columnDataType = function (model, property) { + // ... +}; +``` + +## Implement model discovery from database schemas + +For relational databases that have schema definitions, the connector can +implement the discovery capability to reverse engineer database schemas into +model definitions. + +### Build a SQL statement to list schemas + +```js +/** + * Build sql for listing schemas (databases in MySQL) + * @params {Object} [options] Options object + * @returns {String} The SQL statement + */ + function querySchemas(options) { + // ... + } +``` + +### Build a SQL statement to list tables + +```js +/** + * Build sql for listing tables + * @param options {all: for all owners, owner|schema: for a given owner} + * @returns {string} The sql statement + */ + function queryTables(options) { + // ... + } +```js + +### Build a SQL statement to list views + +```js +/** + * Build sql for listing views + * @param options {all: for all owners, owner: for a given owner} + * @returns {string} The sql statement + */ + function queryViews(options) { + // ... + } +``` + +### Discover schemas + +```js + MySQL.prototype.discoverDatabaseSchemas = function(options, cb) { + // ... + }; +``` + +### Discover a list of models + +```js +/** + * Discover model definitions + * + * @param {Object} options Options for discovery + * @param {Function} [cb] The callback function + */ + MySQL.prototype.discoverModelDefinitions = function(options, cb) { + // ... + }; +``` + +### Discover a list of model properties for a given table + +```js +/** + * Discover model properties from a table + * @param {String} table The table name + * @param {Object} options The options for discovery + * @param {Function} [cb] The callback function + * + */ + MySQL.prototype.discoverModelProperties = function(table, options, cb) { + // ... + }; +``` + +### Discover primary keys for a given table + +```js +/** + * Discover primary keys for a given table + * @param {String} table The table name + * @param {Object} options The options for discovery + * @param {Function} [cb] The callback function + */ + MySQL.prototype.discoverPrimaryKeys = function(table, options, cb) { + // ... + }; +``` + +### Discover foreign keys for a given table + +```js +/** + * Discover foreign keys for a given table + * @param {String} table The table name + * @param {Object} options The options for discovery + * @param {Function} [cb] The callback function + */ + MySQL.prototype.discoverForeignKeys = function(table, options, cb) { + // ... + }; +``` + +### Discover exported foreign keys for a given table + +```js +/** + * Discover foreign keys that reference to the primary key of this table + * @param {String} table The table name + * @param {Object} options The options for discovery + * @param {Function} [cb] The callback function + */ + MySQL.prototype.discoverExportedForeignKeys = function(table, options, cb) { + // ... + }; + ``` + +### Discover indexes for a given table + +```js + MySQL.prototype.discoverIndexes = function(table, options, cb) { + // ... + }; +``` + +### Map column definition to model property definition + +```js + MySQL.prototype.buildPropertyType = function(columnDefinition) { + // ... + } +``` + +### Build SQL statements to discover database objects + +```js +/** + * Build the sql statement to query columns for a given table + * @param schema + * @param table + * @returns {String} The sql statement + */ + function queryColumns(schema, table) { + // ... + } + +/** + * Build the sql statement for querying primary keys of a given table + * @param schema + * @param table + * @returns {string} + */ + function queryPrimaryKeys(schema, table) { + // ... + } + +/** + * Build the sql statement for querying foreign keys of a given table + * @param schema + * @param table + * @returns {string} + */ + function queryForeignKeys(schema, table) { + // ... + } + +/** + * Retrieves a description of the foreign key columns that reference the + * given table's primary key columns (the foreign keys exported by a table). + * They are ordered by fkTableOwner, fkTableName, and keySeq. + * @param schema + * @param table + * @returns {string} + */ + function queryExportedForeignKeys(schema, table) { + // ... + } +``` \ No newline at end of file diff --git a/index.js b/index.js index 5c3f74d..92e0c3a 100644 --- a/index.js +++ b/index.js @@ -1,2 +1,4 @@ exports.Connector = require('./lib/connector'); -exports.SqlConnector = require('./lib/sql'); +// Set up SqlConnector as an alias to SQLConnector +exports.SQLConnector = exports.SqlConnector = require('./lib/sql'); +exports.ParameterizedSQL = exports.SQLConnector.ParameterizedSQL; diff --git a/lib/connector.js b/lib/connector.js index 3eb6c79..99e5d97 100644 --- a/lib/connector.js +++ b/lib/connector.js @@ -1,7 +1,9 @@ +var debug = require('debug')('loopback:connector'); + module.exports = Connector; /** - * Base class for LooopBack connector. This is more a collection of useful + * Base class for LoopBack connector. This is more a collection of useful * methods for connectors than a super class * @constructor */ @@ -17,6 +19,15 @@ function Connector(name, settings) { */ Connector.prototype.relational = false; +/** + * Check if the connector is for a relational DB + * @returns {Boolean} true for relational DB + */ +Connector.prototype.isRelational = function() { + return this.isRelational || + (this.getTypes().indexOf('rdbms') !== -1); +}; + /** * Get types associated with the connector * @returns {String[]} The types for the connector @@ -27,9 +38,11 @@ Connector.prototype.getTypes = function() { /** * Get the default data type for ID + * @param prop Property definition * @returns {Function} The default type for ID */ -Connector.prototype.getDefaultIdType = function() { +Connector.prototype.getDefaultIdType = function(prop) { + /*jshint unused:false */ return String; }; @@ -38,15 +51,16 @@ Connector.prototype.getDefaultIdType = function() { * @returns {Object} The metadata object * @property {String} type The type for the backend * @property {Function} defaultIdType The default id type - * @property {Boolean} [isRelational] If the connector represents a relational database + * @property {Boolean} [isRelational] If the connector represents a relational + * database * @property {Object} schemaForSettings The schema for settings object */ -Connector.prototype.getMedadata = function () { +Connector.prototype.getMetadata = function() { if (!this._metadata) { this._metadata = { types: this.getTypes(), defaultIdType: this.getDefaultIdType(), - isRelational: this.isRelational || (this.getTypes().indexOf('rdbms') !== -1), + isRelational: this.isRelational(), schemaForSettings: {} }; } @@ -55,13 +69,51 @@ Connector.prototype.getMedadata = function () { /** * Execute a command with given parameters - * @param {String} command The command such as SQL - * @param {Object[]} [params] An array of parameters + * @param {String|Object} command The command such as SQL + * @param {*[]} [params] An array of parameter values + * @param {Object} [options] Options object * @param {Function} [callback] The callback function */ -Connector.prototype.execute = function (command, params, callback) { - /*jshint unused:false */ - throw new Error('query method should be declared in connector'); +Connector.prototype.execute = function(command, params, options, callback) { + throw new Error('execute() must be implemented by the connector'); +}; + +/** + * Get the model definition by name + * @param {String} modelName The model name + * @returns {ModelDefinition} The model definition + */ +Connector.prototype.getModelDefinition = function(modelName) { + return this._models[modelName]; +}; + +/** + * Get connector specific settings for a given model, for example, + * ``` + * { + * "postgresql": { + * "schema": "xyz" + * } + * } + * ``` + * + * @param {String} modelName Model name + * @returns {Object} The connector specific settings + */ +Connector.prototype.getConnectorSpecificSettings = function(modelName) { + var settings = this.getModelDefinition(modelName).settings || {}; + return settings[this.name]; +}; + +/** + * Get model property definition + * @param {String} modelName Model name + * @param {String} propName Property name + * @returns {Object} Property definition + */ +Connector.prototype.getPropertyDefinition = function(modelName, propName) { + var model = this.getModelDefinition(modelName); + return model && model.properties[propName]; }; /** @@ -69,10 +121,10 @@ Connector.prototype.execute = function (command, params, callback) { * @param {String} model The model name * @returns {DataSource} The data source */ -Connector.prototype.getDataSource = function (model) { - var m = this._models[model]; +Connector.prototype.getDataSource = function(model) { + var m = this.getModelDefinition(model); if (!m) { - console.trace('Model not found: ' + model); + debug('Model not found: ' + model); } return m && m.model.dataSource; }; @@ -82,7 +134,7 @@ Connector.prototype.getDataSource = function (model) { * @param {String} model The model name * @returns {String} The id property name */ -Connector.prototype.idName = function (model) { +Connector.prototype.idName = function(model) { return this.getDataSource(model).idName(model); }; @@ -91,7 +143,7 @@ Connector.prototype.idName = function (model) { * @param {String} model The model name * @returns {[String]} The id property names */ -Connector.prototype.idNames = function (model) { +Connector.prototype.idNames = function(model) { return this.getDataSource(model).idNames(model); }; @@ -102,8 +154,8 @@ Connector.prototype.idNames = function (model) { * @returns {Number} The id index, undefined if the property is not part * of the primary key */ -Connector.prototype.id = function (model, prop) { - var p = this._models[model].properties[prop]; +Connector.prototype.id = function(model, prop) { + var p = this.getModelDefinition(model).properties[prop]; return p && p.id; }; @@ -111,10 +163,8 @@ Connector.prototype.id = function (model, prop) { * Hook to be called by DataSource for defining a model * @param {Object} modelDefinition The model definition */ -Connector.prototype.define = function (modelDefinition) { - if (!modelDefinition.settings) { - modelDefinition.settings = {}; - } +Connector.prototype.define = function(modelDefinition) { + modelDefinition.settings = modelDefinition.settings || {}; this._models[modelDefinition.model.modelName] = modelDefinition; }; @@ -122,18 +172,22 @@ Connector.prototype.define = function (modelDefinition) { * Hook to be called by DataSource for defining a model property * @param {String} model The model name * @param {String} propertyName The property name - * @param {Object} propertyDefinition The object for property metadata + * @param {Object} propertyDefinition The object for property definition */ -Connector.prototype.defineProperty = function (model, propertyName, propertyDefinition) { - this._models[model].properties[propertyName] = propertyDefinition; -}; +Connector.prototype.defineProperty = function(model, propertyName, propertyDefinition) { + var modelDef = this.getModelDefinition(model); + modelDef.properties[propertyName] = propertyDefinition; + }; /** * Disconnect from the connector + * @param {Function} [cb] Callback function */ Connector.prototype.disconnect = function disconnect(cb) { // NO-OP - if (cb) process.nextTick(cb); + if (cb) { + process.nextTick(cb); + } }; /** @@ -143,7 +197,7 @@ Connector.prototype.disconnect = function disconnect(cb) { * @returns {*} The id value * */ -Connector.prototype.getIdValue = function (model, data) { +Connector.prototype.getIdValue = function(model, data) { return data && data[this.idName(model)]; }; @@ -154,16 +208,69 @@ Connector.prototype.getIdValue = function (model, data) { * @param {*} value The id value * */ -Connector.prototype.setIdValue = function (model, data, value) { +Connector.prototype.setIdValue = function(model, data, value) { if (data) { data[this.idName(model)] = value; } }; -Connector.prototype.getType = function () { - return this.type; +/** + * Test if a property is nullable + * @param {Object} prop The property definition + * @returns {boolean} true if nullable + */ +Connector.prototype.isNullable = function(prop) { + if (prop.required || prop.id) { + return false; + } + if (prop.nullable || prop['null'] || prop.allowNull) { + return true; + } + if (prop.nullable === false || prop['null'] === false || + prop.allowNull === false) { + return false; + } + return true; }; +/** + * Return the DataAccessObject interface implemented by the connector + * @returns {Object} An object containing all methods implemented by the + * connector that can be mixed into the model class. It should be considered as + * the interface. + */ +Connector.prototype.getDataAccessObject = function() { + return this.DataAccessObject; +}; + +/*! + * Define aliases to a prototype method/property + * @param {Function} cls The class that owns the method/property + * @param {String} methodOrPropertyName The official property method/property name + * @param {String|String[]} aliases Aliases to the official property/method + */ +Connector.defineAliases = function(cls, methodOrPropertyName, aliases) { + if (typeof aliases === 'string') { + aliases = [aliases]; + } + if (Array.isArray(aliases)) { + aliases.forEach(function(alias) { + if (typeof alias === 'string') { + Object.defineProperty(cls, alias, { + get: function() { + return this[methodOrPropertyName]; + } + }); + } + }); + } +}; + +/** + * `command()` and `query()` are aliases to `execute()` + */ +Connector.defineAliases(Connector.prototype, 'execute', ['command', 'query']); + diff --git a/lib/parameterized-sql.js b/lib/parameterized-sql.js new file mode 100644 index 0000000..15d5bf0 --- /dev/null +++ b/lib/parameterized-sql.js @@ -0,0 +1,100 @@ +var assert = require('assert'); +var PLACEHOLDER = '?'; + +module.exports = ParameterizedSQL; + +/** + * A class for parameterized SQL clauses + * @param {String|Object} sql The SQL clause. If the value is a string, treat + * it as the template using `?` as the placeholder, for example, `(?,?)`. If + * the value is an object, treat it as {sql: '...', params: [...]} + * @param {*[]} params An array of parameter values. The length should match the + * number of placeholders in the template + * @returns {ParameterizedSQL} A new instance of ParameterizedSQL + * @constructor + */ +function ParameterizedSQL(sql, params) { + if (!(this instanceof ParameterizedSQL)) { + return new ParameterizedSQL(sql, params); + } + sql = sql || ''; + if (arguments.length === 1 && typeof sql === 'object') { + this.sql = sql.sql; + this.params = sql.params || []; + } else { + this.sql = sql; + this.params = params || []; + } + assert(typeof this.sql === 'string', 'sql must be a string'); + assert(Array.isArray(this.params), 'params must be an array'); + + var parts = this.sql.split(PLACEHOLDER); + assert(parts.length - 1 === this.params.length, + 'The number of ? (' + (parts.length - 1) + + ') in the sql (' + this.sql + ') must match the number of params (' + + this.params.length + + ') ' + this.params); +} + +/** + * Merge the parameterized sqls into the current instance + * @param {Object|Object[]} ps A parametered SQL or an array of parameterized + * SQLs + * @param {String} [separator] Separator, default to ` ` + * @returns {ParameterizedSQL} The current instance + */ +ParameterizedSQL.prototype.merge = function(ps, separator) { + if (Array.isArray(ps)) { + return this.constructor.append(this, + this.constructor.join(ps, separator), separator); + } else { + return this.constructor.append(this, ps, separator); + } +}; + +ParameterizedSQL.prototype.toJSON = function() { + return { + sql: this.sql, + params: this.params + }; +}; + +/** + * Append the statement into the current statement + * @param {Object} currentStmt The current SQL statement + * @param {Object} stmt The statement to be appended + * @param {String} [separator] Separator, default to ` ` + * @returns {*} The merged statement + */ +ParameterizedSQL.append = function(currentStmt, stmt, separator) { + currentStmt = (currentStmt instanceof ParameterizedSQL) ? + currentStmt : new ParameterizedSQL(currentStmt); + stmt = (stmt instanceof ParameterizedSQL) ? stmt : + new ParameterizedSQL(stmt); + separator = typeof separator === 'string' ? separator : ' '; + if (currentStmt.sql) { + currentStmt.sql += separator; + } + if (stmt.sql) { + currentStmt.sql += stmt.sql; + } + currentStmt.params = currentStmt.params.concat(stmt.params); + return currentStmt; +}; + +/** + * Join multiple parameterized SQLs into one + * @param {Object[]} sqls An array of parameterized SQLs + * @param {String} [separator] Separator, default to ` ` + * @returns {ParameterizedSQL} + */ +ParameterizedSQL.join = function(sqls, separator) { + assert(Array.isArray(sqls), 'sqls must be an array'); + var ps = new ParameterizedSQL('', []); + for (var i = 0, n = sqls.length; i < n; i++) { + this.append(ps, sqls[i], separator); + } + return ps; +}; + +ParameterizedSQL.PLACEHOLDER = PLACEHOLDER; diff --git a/lib/sql.js b/lib/sql.js index 29b4617..440884b 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -2,105 +2,184 @@ var util = require('util'); var async = require('async'); var assert = require('assert'); var Connector = require('./connector'); +var debug = require('debug')('loopback:connector:sql'); +var ParameterizedSQL = require('./parameterized-sql'); -module.exports = SqlConnector; +module.exports = SQLConnector; /** - * Base class for connectors that are backed by relational databases/SQL + * Base class for connectors that connect to relational databases using SQL * @class */ -function SqlConnector() { +function SQLConnector() { + // Call the super constructor Connector.apply(this, [].slice.call(arguments)); } -util.inherits(SqlConnector, Connector); +// Inherit from the base Connector +util.inherits(SQLConnector, Connector); + +// Export ParameterizedSQL +SQLConnector.ParameterizedSQL = ParameterizedSQL; + +// The generic placeholder +var PLACEHOLDER = SQLConnector.PLACEHOLDER = ParameterizedSQL.PLACEHOLDER; /** * Set the relational property to indicate the backend is a relational DB * @type {boolean} */ -SqlConnector.prototype.relational = true; +SQLConnector.prototype.relational = true; + +/** + * Invoke a prototype method on the super class + * @param {String} methodName Method name + */ +SQLConnector.prototype.invokeSuper = function(methodName) { + var args = [].slice.call(arguments, 1); + var superMethod = this.constructor.super_.prototype[methodName]; + return superMethod.apply(this, args); +}; /** * Get types associated with the connector * Returns {String[]} The types for the connector */ -SqlConnector.prototype.getTypes = function() { +SQLConnector.prototype.getTypes = function() { return ['db', 'rdbms', 'sql']; }; -/*! +/** * Get the default data type for ID + * @param prop Property definition * Returns {Function} */ -SqlConnector.prototype.getDefaultIdType = function() { +SQLConnector.prototype.getDefaultIdType = function(prop) { + /*jshint unused:false */ return Number; }; -SqlConnector.prototype.query = function () { - throw new Error('query method should be declared in connector'); -}; - -SqlConnector.prototype.command = function (sql, params, callback) { - return this.query(sql, params, callback); -}; - -SqlConnector.prototype.queryOne = function (sql, callback) { - return this.query(sql, function (err, data) { - if (err) { - return callback(err); - } - callback(err, data && data[0]); - }); +/** + * Get the default database schema name + * @returns {string} The default schema name, such as 'public' or 'dbo' + */ +SQLConnector.prototype.getDefaultSchemaName = function() { + return ''; }; /** - * Get the table name for a given model. + * Get the database schema name for the given model. The schema name can be + * customized at model settings or connector configuration level as `schema` or + * `schemaName`. For example, + * + * ```json + * "Customer": { + * "name": "Customer", + * "mysql": { + * "schema": "MYDB", + * "table": "CUSTOMER" + * } + * } + * ``` + * + * @param {String} model The model name + * @returns {String} The database schema name + */ +SQLConnector.prototype.schema = function(model) { + // Check if there is a 'schema' property for connector + var dbMeta = this.getConnectorSpecificSettings(model); + var schemaName = (dbMeta && (dbMeta.schema || dbMeta.schemaName)) || + (this.settings.schema || this.settings.schemaName) || + this.getDefaultSchemaName(); + return schemaName; +}; + +/** + * Get the table name for the given model. The table name can be customized + * at model settings as `table` or `tableName`. For example, + * + * ```json + * "Customer": { + * "name": "Customer", + * "mysql": { + * "table": "CUSTOMER" + * } + * } + * ``` + * * Returns the table name (String). * @param {String} model The model name */ -SqlConnector.prototype.table = function (model) { - var name = this.getDataSource(model).tableName(model); - var dbName = this.dbName; - if (typeof dbName === 'function') { - name = dbName(name); +SQLConnector.prototype.table = function(model) { + var dbMeta = this.getConnectorSpecificSettings(model); + var tableName; + if (dbMeta) { + tableName = dbMeta.table || dbMeta.tableName; + if (tableName) { + // Explicit table name, return as-is + return tableName; + } } - return name; + tableName = model; + if (typeof this.dbName === 'function') { + tableName = this.dbName(tableName); + } + return tableName; }; /** - * Get the column name for given model property + * Get the column name for the given model property. The column name can be + * customized at the model property definition level as `column` or + * `columnName`. For example, + * + * ```json + * "name": { + * "type": "string", + * "mysql": { + * "column": "NAME" + * } + * } + * ``` + * * @param {String} model The model name * @param {String} property The property name * @returns {String} The column name */ -SqlConnector.prototype.column = function (model, property) { - var name = this.getDataSource(model).columnName(model, property); - var dbName = this.dbName; - if (typeof dbName === 'function') { - name = dbName(name); +SQLConnector.prototype.column = function(model, property) { + var prop = this.getPropertyDefinition(model, property); + var columnName; + if (prop && prop[this.name]) { + columnName = prop[this.name].column || prop[this.name].columnName; + if (columnName) { + // Explicit column name, return as-is + return columnName; + } } - return name; + columnName = property; + if (typeof this.dbName === 'function') { + columnName = this.dbName(columnName); + } + return columnName; }; /** - * Get the column name for given model property + * Get the column metadata for the given model property * @param {String} model The model name * @param {String} property The property name * @returns {Object} The column metadata */ -SqlConnector.prototype.columnMetadata = function (model, property) { +SQLConnector.prototype.columnMetadata = function(model, property) { return this.getDataSource(model).columnMetadata(model, property); }; /** - * Get the corresponding property name for a given column name + * Get the corresponding property name for the given column name * @param {String} model The model name * @param {String} column The column name * @returns {String} The property name for a given column */ -SqlConnector.prototype.propertyName = function (model, column) { - var props = this._models[model].properties; +SQLConnector.prototype.propertyName = function(model, column) { + var props = this.getModelDefinition(model).properties; for (var p in props) { if (this.column(model, p) === column) { return p; @@ -112,9 +191,9 @@ SqlConnector.prototype.propertyName = function (model, column) { /** * Get the id column name * @param {String} model The model name - * @returns {String} The column name + * @returns {String} The id column name */ -SqlConnector.prototype.idColumn = function (model) { +SQLConnector.prototype.idColumn = function(model) { var name = this.getDataSource(model).idColumnName(model); var dbName = this.dbName; if (typeof dbName === 'function') { @@ -128,17 +207,8 @@ SqlConnector.prototype.idColumn = function (model) { * @param {String} model The model name * @returns {String} the escaped id column name */ -SqlConnector.prototype.idColumnEscaped = function (model) { - return this.escapeName(this.getDataSource(model).idColumnName(model)); -}; - -/** - * Escape the name for the underlying database - * @param {String} name The name - */ -SqlConnector.prototype.escapeName = function (name) { - /*jshint unused:false */ - throw new Error('escapeName method should be declared in connector'); +SQLConnector.prototype.idColumnEscaped = function(model) { + return this.escapeName(this.idColumn(model)); }; /** @@ -146,7 +216,7 @@ SqlConnector.prototype.escapeName = function (name) { * @param {String} model The model name * @returns {String} the escaped table name */ -SqlConnector.prototype.tableEscaped = function (model) { +SQLConnector.prototype.tableEscaped = function(model) { return this.escapeName(this.table(model)); }; @@ -156,200 +226,913 @@ SqlConnector.prototype.tableEscaped = function (model) { * @param {String} property The property name * @returns {String} The escaped column name */ -SqlConnector.prototype.columnEscaped = function (model, property) { +SQLConnector.prototype.columnEscaped = function(model, property) { return this.escapeName(this.column(model, property)); }; -function isIdValuePresent(idValue, callback, returningNull) { +/*! + * Check if id value is set + * @param idValue + * @param cb + * @param returningNull + * @returns {boolean} + */ +function isIdValuePresent(idValue, cb, returningNull) { try { assert(idValue !== null && idValue !== undefined, 'id value is required'); return true; } catch (err) { - process.nextTick(function () { - if(callback) callback(returningNull ? null: err); + process.nextTick(function() { + if (cb) cb(returningNull ? null : err); }); return false; } } + /** - * Save the model instance into the backend store + * Convert the id value to the form required by database column + * @param {String} model The model name + * @param {*} idValue The id property value + * @returns {*} The escaped id column value + */ +SQLConnector.prototype.idColumnValue = function(model, idValue) { + var idProp = this.getDataSource(model).idProperty(model); + if (typeof this.toColumnValue === 'function') { + return this.toColumnValue(idProp, idValue); + } else { + return idValue; + } +}; + +/** + * Replace `?` with connector specific placeholders. For example, + * + * ``` + * {sql: 'SELECT * FROM CUSTOMER WHERE NAME=?', params: ['John']} + * ==> + * {sql: 'SELECT * FROM CUSTOMER WHERE NAME=:1', params: ['John']} + * ``` + * *LIMITATION*: We don't handle the ? inside escaped values, for example, + * `SELECT * FROM CUSTOMER WHERE NAME='J?hn'` will not be parameterized + * correctly. + * + * @param {ParameterizedSQL|Object} ps Parameterized SQL + * @returns {ParameterizedSQL} Parameterized SQL with the connector specific + * placeholders + */ +SQLConnector.prototype.parameterize = function(ps) { + ps = new ParameterizedSQL(ps); + + // The value is parameterized, for example + // {sql: 'to_point(?,?)', values: [1, 2]} + var parts = ps.sql.split(PLACEHOLDER); + var clause = []; + for (var j = 0, m = parts.length; j < m; j++) { + // Replace ? with the keyed placeholder, such as :5 + clause.push(parts[j]); + if (j !== parts.length - 1) { + clause.push(this.getPlaceholderForValue(j + 1)); + } + } + ps.sql = clause.join(''); + return ps; +}; + +/** + * Build the the `INSERT INTO` statement + * @param {String} model The model name + * @param {Object} fields Fields to be inserted + * @param {Object} options Options object + * @returns {ParameterizedSQL} + */ +SQLConnector.prototype.buildInsertInto = function(model, fields, options) { + var stmt = new ParameterizedSQL('INSERT INTO ' + this.tableEscaped(model)); + var columnNames = fields.names.join(','); + if (columnNames) { + stmt.merge('(' + columnNames + ')', ''); + } + return stmt; +}; + +/** + * Build the clause to return id values after insert + * @param {String} model The model name + * @param {Object} data The model data object + * @param {Object} options Options object + * @returns {string} + */ +SQLConnector.prototype.buildInsertReturning = function(model, data, options) { + return ''; +}; + +/** + * Build the clause for default values if the fields is empty + * @param {String} model The model name + * @param {Object} data The model data object + * @param {Object} options Options object + * @returns {string} 'DEFAULT VALUES' + */ +SQLConnector.prototype.buildInsertDefaultValues = function(model, data, options) { + return 'VALUES()'; +}; + +/** + * Build INSERT SQL statement + * @param {String} model The model name + * @param {Object} data The model data object + * @param {Object} options The options object + * @returns {string} The INSERT SQL statement + */ +SQLConnector.prototype.buildInsert = function(model, data, options) { + var fields = this.buildFields(model, data); + var insertStmt = this.buildInsertInto(model, fields, options); + var columnValues = fields.columnValues; + var fieldNames = fields.names; + if (fieldNames.length) { + var values = ParameterizedSQL.join(columnValues, ','); + values.sql = 'VALUES(' + values.sql + ')'; + insertStmt.merge(values); + } else { + insertStmt.merge(this.buildInsertDefaultValues(model, data, options)); + } + var returning = this.buildInsertReturning(model, data, options); + if (returning) { + insertStmt.merge(returning); + } + return this.parameterize(insertStmt); +}; + +/** + * Execute a SQL statement with given parameters. + * + * @param {String} sql The SQL statement + * @param {*[]} [params] An array of parameter values + * @param {Object} [options] Options object + * @param {Function} [callback] The callback function + */ +SQLConnector.prototype.execute = function(sql, params, options, callback) { + assert(typeof sql === 'string', 'sql must be a string'); + if (typeof params === 'function' && options === undefined && + callback === undefined) { + // execute(sql, callback) + options = {}; + callback = params; + params = []; + } else if (typeof options === 'function' && callback === undefined) { + // execute(sql, params, callback) + callback = options; + options = {}; + } + params = params || []; + options = options || {}; + assert(Array.isArray(params), 'params must be an array'); + assert(typeof options === 'object', 'options must be an object'); + assert(typeof callback === 'function', 'callback must be a function'); + + var self = this; + if (!this.dataSource.connected) { + return this.dataSource.once('connected', function() { + self.execute(sql, params, options, callback); + }); + } + this.executeSQL(sql, params, options, callback); +}; + +/** + * Create the data model in MySQL + * * @param {String} model The model name * @param {Object} data The model instance data - * @param {Function} callback The callback function + * @param {Object} options Options object + * @param {Function} [callback] The callback function */ -SqlConnector.prototype.save = function (model, data, callback) { - var idName = this.getDataSource(model).idName(model); +SQLConnector.prototype.create = function(model, data, options, callback) { + var self = this; + var stmt = this.buildInsert(model, data, options); + this.execute(stmt.sql, stmt.params, options, function(err, info) { + if (err) { + callback(err); + } else { + var insertedId = self.getInsertedId(model, info); + callback(err, insertedId); + } + }); +}; + +/** + * Save the model instance into the database + * @param {String} model The model name + * @param {Object} data The model instance data + * @param {Object} options Options object + * @param {Function} cb The callback function + */ +SQLConnector.prototype.save = function(model, data, options, cb) { + var idName = this.idName(model); var idValue = data[idName]; - if (!isIdValuePresent(idValue, callback)) { + if (!isIdValuePresent(idValue, cb)) { return; } - idValue = this._escapeIdValue(model, idValue); - var sql = 'UPDATE ' + this.tableEscaped(model) + ' SET ' + - this.toFields(model, data) + - ' WHERE ' + this.idColumnEscaped(model) + ' = ' + idValue; + var where = {}; + where[idName] = idValue; - this.query(sql, function (err, result) { - if (callback) callback(err, result); - }); + var updateStmt = new ParameterizedSQL('UPDATE ' + this.tableEscaped(model)); + updateStmt.merge(this.buildFieldsForUpdate(model, data)); + var whereStmt = this.buildWhere(model, where); + updateStmt.merge(whereStmt); + updateStmt = this.parameterize(updateStmt); + this.execute(updateStmt.sql, updateStmt.params, options, + function(err, result) { + if (cb) cb(err, result); + }); }; /** * Check if a model instance exists for the given id value * @param {String} model The model name * @param {*} id The id value - * @param {Function} callback The callback function + * @param {Object} options Options object + * @param {Function} cb The callback function */ -SqlConnector.prototype.exists = function (model, id, callback) { - if (!isIdValuePresent(id, callback, true)) { +SQLConnector.prototype.exists = function(model, id, options, cb) { + if (!isIdValuePresent(id, cb, true)) { return; } - var sql = 'SELECT 1 FROM ' + - this.tableEscaped(model) + ' WHERE ' + - this.idColumnEscaped(model) + ' = ' + this._escapeIdValue(model, id) + - ' LIMIT 1'; + var idName = this.idName(model); + var where = {}; + where[idName] = id; + var selectStmt = new ParameterizedSQL( + 'SELECT 1 FROM ' + this.tableEscaped(model) + + ' WHERE ' + this.idColumnEscaped(model) + ); + selectStmt.merge(this.buildWhere(model, where)); - this.query(sql, function (err, data) { - if (!callback) return; + selectStmt = this.applyPagination(model, selectStmt, { + limit: 1, + offset: 0, + order: [idName] + }); + selectStmt = this.parameterize(selectStmt); + + this.execute(selectStmt.sql, selectStmt.params, options, function(err, data) { + if (!cb) return; if (err) { - callback(err); + cb(err); } else { - callback(null, data.length >= 1); + cb(null, data.length >= 1); } }); }; /** - * Find a model instance by id - * @param {String} model The model name - * @param {*} id The id value - * @param {Function} callback The callback function - */ -SqlConnector.prototype.find = function find(model, id, callback) { - if (!isIdValuePresent(id, callback, true)) { - return; - } - var self = this; - var idQuery = this.idColumnEscaped(model) + ' = ' + this._escapeIdValue(model, id); - var sql = 'SELECT * FROM ' + - this.tableEscaped(model) + ' WHERE ' + idQuery + ' LIMIT 1'; - - this.query(sql, function (err, data) { - var result = (data && data.length >= 1) ? data[0] : null; - if (callback) callback(err, self.fromDatabase(model, result)); - }); -}; - -/** + * ATM, this method is not used by loopback-datasource-juggler dao, which + * maps `destroy` to `destroyAll` with a `where` filter that includes the `id` + * instead. + * * Delete a model instance by id value * @param {String} model The model name * @param {*} id The id value - * @param {Function} callback The callback function + * @param {Object} options Options object + * @param {Function} cb The callback function + * @private */ -SqlConnector.prototype.delete = -SqlConnector.prototype.destroy = function destroy(model, id, callback) { - if (!isIdValuePresent(id, callback, true)) { +SQLConnector.prototype.destroy = function(model, id, options, cb) { + if (!isIdValuePresent(id, cb, true)) { return; } - var sql = 'DELETE FROM ' + this.tableEscaped(model) + ' WHERE ' + - this.idColumnEscaped(model) + ' = ' + this._escapeIdValue(model, id); - - this.command(sql, function (err, result) { - if (callback) callback(err, result); - }); + var idName = this.idName(model); + var where = {}; + where[idName] = id; + this.destroyAll(model, where, options, cb); }; - -SqlConnector.prototype._escapeIdValue = function(model, idValue) { - var idProp = this.getDataSource(model).idProperty(model); - if(typeof this.toDatabase === 'function') { - return this.toDatabase(idProp, idValue); - } else { - if(idProp.type === Number) { - return idValue; - } else { - return '\'' + idValue + '\''; - } - } -}; - -function buildWhere(self, model, where) { - if (typeof self.buildWhere === 'function') { - return self.buildWhere(model, where); - } else { - var props = self._models[model].properties; - var cs = []; - Object.keys(where || {}).forEach(function (key) { - var keyEscaped = self.columnEscaped(model, key); - if (where[key] === null) { - cs.push(keyEscaped + ' IS NULL'); - } else { - cs.push(keyEscaped + ' = ' + self.toDatabase(props[key], where[key])); - } - }); - return cs.length ? ' WHERE ' + cs.join(' AND ') : ''; - } -} +// Alias to `destroy`. Juggler checks `destroy` only. +Connector.defineAliases(SQLConnector.prototype, 'destroy', + ['delete', 'deleteById', 'destroyById']); /** - * Delete all model instances + * Build the `DELETE FROM` SQL statement + * @param {String} model The model name + * @param {Object} where The where object + * @param {Object} options Options object + * @returns {ParameterizedSQL} The SQL DELETE FROM statement + */ +SQLConnector.prototype.buildDelete = function(model, where, options) { + var deleteStmt = new ParameterizedSQL('DELETE FROM ' + + this.tableEscaped(model)); + deleteStmt.merge(this.buildWhere(model, where)); + return this.parameterize(deleteStmt); +}; + +/** + * Delete all matching model instances * * @param {String} model The model name - * @param {Function} callback The callback function + * @param {Object} where The where object + * @param {Object} options The options object + * @param {Function} cb The callback function */ -SqlConnector.prototype.deleteAll = - SqlConnector.prototype.destroyAll = function destroyAll(model, where, callback) { - this.command('DELETE FROM ' + this.tableEscaped(model) + - buildWhere(this, model, where), function (err, result) { - if (callback) { - callback(err, result); +SQLConnector.prototype.destroyAll = function(model, where, options, cb) { + var self = this; + var stmt = this.buildDelete(model, where, options); + this.execute(stmt.sql, stmt.params, options, function(err, info) { + var affectedRows = self.getCountForAffectedRows(model, info); + if (cb) { + cb(err, {count: affectedRows}); + } + }); +}; +// Alias to `destroyAll`. Juggler checks `destroyAll` only. +Connector.defineAliases(SQLConnector.prototype, 'destroyAll', ['deleteAll']); + +/** + * ATM, this method is not used by loopback-datasource-juggler dao, which + * maps `updateAttributes` to `update` with a `where` filter that includes the + * `id` instead. + * + * Update attributes for a given model instance + * @param {String} model The model name + * @param {*} id The id value + * @param {Object} data The model data instance containing all properties to + * be updated + * @param {Object} options Options object + * @param {Function} cb The callback function + * @private + */ +SQLConnector.prototype.updateAttributes = function(model, id, data, options, cb) { + if (!isIdValuePresent(id, cb)) { + return; + } + var idName = this.idName(model); + delete data[idName]; + var where = {}; + where[idName] = id; + this.updateAll(model, where, data, options, cb); +}; + +/** + * Build the UPDATE statement + * @param {String} model The model name + * @param {Object} where The where object + * @param {Object} data The data to be changed + * @param {Object} options The options object + * @param {Function} cb The callback function + * @returns {ParameterizedSQL} The UPDATE SQL statement + */ +SQLConnector.prototype.buildUpdate = function(model, where, data, options) { + var fields = this.buildFieldsForUpdate(model, data); + var updateClause = new ParameterizedSQL('UPDATE ' + this.tableEscaped(model)); + var whereClause = this.buildWhere(model, where); + updateClause.merge([fields, whereClause]); + return this.parameterize(updateClause); +}; + +/** + * Update all instances that match the where clause with the given data + * @param {String} model The model name + * @param {Object} where The where object + * @param {Object} data The property/value object representing changes + * to be made + * @param {Object} options The options object + * @param {Function} cb The callback function + */ +SQLConnector.prototype.update = function(model, where, data, options, cb) { + var self = this; + var stmt = this.buildUpdate(model, where, data, options); + this.execute(stmt.sql, stmt.params, options, function(err, info) { + var affectedRows = self.getCountForAffectedRows(model, info); + if (cb) { + cb(err, {count: affectedRows}); + } + }); +}; +// Alias to `update`. Juggler checks `update` only. +Connector.defineAliases(SQLConnector.prototype, 'update', ['updateAll']); + +/** + * Build the SQL WHERE clause for the where object + * @param {string} model Model name + * @param {object} where An object for the where conditions + * @returns {ParameterizedSQL} The SQL WHERE clause + */ +SQLConnector.prototype.buildWhere = function(model, where) { + var whereClause = this._buildWhere(model, where); + if (whereClause.sql) { + whereClause.sql = 'WHERE ' + whereClause.sql; + } + return whereClause; +}; + +/** + * Build SQL expression + * @param {String} columnName Escaped column name + * @param {String} operator SQL operator + * @param {*} columnValue Column value + * @param {*} propertyValue Property value + * @returns {ParameterizedSQL} The SQL expression + */ +SQLConnector.prototype.buildExpression = function(columnName, operator, columnValue, propertyValue) { + function buildClause(columnValue, separator, grouping) { + var values = []; + for (var i = 0, n = columnValue.length; i < n; i++) { + if (columnValue instanceof ParameterizedSQL) { + values.push(columnValue[i]); + } else { + values.push(new ParameterizedSQL(PLACEHOLDER, [columnValue[i]])); + } + } + separator = separator || ','; + var clause = ParameterizedSQL.join(values, separator); + if (grouping) { + clause.sql = '(' + clause.sql + ')'; + } + return clause; + } + + var sqlExp = columnName; + var clause; + if (columnValue instanceof ParameterizedSQL) { + clause = columnValue; + } else { + clause = new ParameterizedSQL(PLACEHOLDER, [columnValue]); + } + switch (operator) { + case 'gt': + sqlExp += '>'; + break; + case 'gte': + sqlExp += '>='; + break; + case 'lt': + sqlExp += '<'; + break; + case 'lte': + sqlExp += '<='; + break; + case 'between': + sqlExp += ' BETWEEN '; + clause = buildClause(columnValue, ' AND ', false); + break; + case 'inq': + sqlExp += ' IN '; + clause = buildClause(columnValue, ',', true); + break; + case 'nin': + sqlExp += ' NOT IN '; + clause = buildClause(columnValue, ',', true); + break; + case 'neq': + if (columnValue == null) { + return new ParameterizedSQL(sqlExp + ' IS NOT NULL'); + } + sqlExp += '!='; + break; + case 'like': + sqlExp += ' LIKE '; + break; + case 'nlike': + sqlExp += ' NOT LIKE '; + break; + } + var stmt = ParameterizedSQL.join([sqlExp, clause], ''); + return stmt; +}; + +/*! + * @param model + * @param where + * @returns {ParameterizedSQL} + * @private + */ +SQLConnector.prototype._buildWhere = function(model, where) { + if (!where) { + return new ParameterizedSQL(''); + } + if (typeof where !== 'object' || Array.isArray(where)) { + debug('Invalid value for where: %j', where); + return new ParameterizedSQL(''); + } + var self = this; + var props = self.getModelDefinition(model).properties; + + var whereStmts = []; + for (var key in where) { + var stmt = new ParameterizedSQL('', []); + // Handle and/or operators + if (key === 'and' || key === 'or') { + var branches = []; + var branchParams = []; + var clauses = where[key]; + if (Array.isArray(clauses)) { + for (var i = 0, n = clauses.length; i < n; i++) { + var stmtForClause = self._buildWhere(model, clauses[i]); + stmtForClause.sql = '(' + stmtForClause.sql + ')'; + branchParams = branchParams.concat(stmtForClause.params); + branches.push(stmtForClause.sql); + } + stmt.merge({ + sql: branches.join(' ' + key.toUpperCase() + ' '), + params: branchParams + }); + whereStmts.push(stmt); + continue; + } + // The value is not an array, fall back to regular fields + } + var columnName = self.columnEscaped(model, key); + var expression = where[key]; + var columnValue; + var sqlExp; + if (expression === null || expression === undefined) { + stmt.merge(columnName + ' IS NULL'); + } else if (expression && + (typeof expression === 'object' && !Array.isArray(expression))) { + var operator = Object.keys(expression)[0]; + // Get the expression without the operator + expression = expression[operator]; + if (operator === 'inq' || operator === 'nin' || operator === 'between') { + columnValue = []; + if (Array.isArray(expression)) { + // Column value is a list + for (var j = 0, m = expression.length; j < m; j++) { + columnValue.push(this.toColumnValue(props[key], expression[j])); + } + } else { + columnValue.push(this.toColumnValue(props[key], expression)); + } + if (operator === 'between') { + // BETWEEN v1 AND v2 + var v1 = columnValue[0] === undefined ? null : columnValue[0]; + var v2 = columnValue[1] === undefined ? null : columnValue[1]; + columnValue = [v1, v2]; + } else { + // IN (v1,v2,v3) or NOT IN (v1,v2,v3) + if (columnValue.length === 0) { + if (operator === 'inq') { + columnValue = [null]; + } else { + // nin () is true + continue; + } + } + } + } else { + columnValue = this.toColumnValue(props[key], expression); + } + sqlExp = self.buildExpression( + columnName, operator, columnValue, props[key]); + stmt.merge(sqlExp); + } else { + // The expression is the field value, not a condition + columnValue = self.toColumnValue(props[key], expression); + if (columnValue === null) { + stmt.merge(columnName + ' IS NULL'); + } else { + if (columnValue instanceof ParameterizedSQL) { + stmt.merge(columnName + '=').merge(columnValue); + } else { + stmt.merge({ + sql: columnName + '=?', + params: [columnValue] + }); + } + } + } + whereStmts.push(stmt); + } + var params = []; + var sqls = []; + for (var k = 0, s = whereStmts.length; k < s; k++) { + sqls.push(whereStmts[k].sql); + params = params.concat(whereStmts[k].params); + } + var whereStmt = new ParameterizedSQL({ + sql: sqls.join(' AND '), + params: params + }); + return whereStmt; +}; + +/** + * Build the ORDER BY clause + * @param {string} model Model name + * @param {string[]} order An array of sorting criteria + * @returns {string} The ORDER BY clause + */ +SQLConnector.prototype.buildOrderBy = function(model, order) { + if (!order) { + return ''; + } + var self = this; + if (typeof order === 'string') { + order = [order]; + } + var clauses = []; + for (var i = 0, n = order.length; i < n; i++) { + var t = order[i].split(/[\s,]+/); + if (t.length === 1) { + clauses.push(self.columnEscaped(model, order[i])); + } else { + clauses.push(self.columnEscaped(model, t[0]) + ' ' + t[1]); + } + } + return 'ORDER BY ' + clauses.join(','); +}; + +/** + * Build an array of fields for the database operation + * @param {String} model Model name + * @param {Object} data Model data object + * @param {Boolean} excludeIds Exclude id properties or not, default to false + * @returns {{names: Array, values: Array, properties: Array}} + */ +SQLConnector.prototype.buildFields = function(model, data, excludeIds) { + var fields = { + names: [], // field names + columnValues: [], // an array of ParameterizedSQL + properties: [] // model properties + }; + var props = this.getModelDefinition(model).properties; + var keys = Object.keys(data); + for (var i = 0, n = keys.length; i < n; i++) { + var key = keys[i]; + var p = props[key]; + if (excludeIds && p.id) { + continue; + } + if (p) { + var k = this.columnEscaped(model, key); + var v = this.toColumnValue(p, data[key]); + if (v !== undefined) { + fields.names.push(k); + if (v instanceof ParameterizedSQL) { + fields.columnValues.push(v); + } else { + fields.columnValues.push(new ParameterizedSQL(PLACEHOLDER, [v])); + } + fields.properties.push(p); + } + } + } + return fields; +}; + +/** + * Build the SET clause for database update + * @param {String} model Model na + * @param {Object} data The model data object + * @param {Boolean} excludeIds Exclude id properties or not, default to true + * @returns {string} The list of fields for update + */ +SQLConnector.prototype.buildFieldsForUpdate = function(model, data, excludeIds) { + if (excludeIds === undefined) { + excludeIds = true; + } + var fields = this.buildFields(model, data, excludeIds); + var columns = new ParameterizedSQL(''); + for (var i = 0, n = fields.names.length; i < n; i++) { + var clause = ParameterizedSQL.append(fields.names[i], + fields.columnValues[i], '='); + columns.merge(clause, ','); + } + columns.sql = 'SET ' + columns.sql; + return columns; +}; + +/** + * Build a list of escaped column names for the given model and fields filter + * @param {string} model Model name + * @param {object} filter The filter object + * @returns {string} Comma separated string of escaped column names + */ +SQLConnector.prototype.buildColumnNames = function(model, filter) { + var fieldsFilter = filter && filter.fields; + var cols = this.getModelDefinition(model).properties; + if (!cols) { + return '*'; + } + var self = this; + var keys = Object.keys(cols); + if (Array.isArray(fieldsFilter) && fieldsFilter.length > 0) { + // No empty array, including all the fields + keys = fieldsFilter; + } else if ('object' === typeof fieldsFilter && + Object.keys(fieldsFilter).length > 0) { + // { field1: boolean, field2: boolean ... } + var included = []; + var excluded = []; + keys.forEach(function(k) { + if (fieldsFilter[k]) { + included.push(k); + } else if ((k in fieldsFilter) && !fieldsFilter[k]) { + excluded.push(k); } }); - }; + if (included.length > 0) { + keys = included; + } else if (excluded.length > 0) { + excluded.forEach(function(e) { + var index = keys.indexOf(e); + keys.splice(index, 1); + }); + } + } + var names = keys.map(function(c) { + return self.columnEscaped(model, c); + }); + return names.join(','); +}; + +/** + * Build a SQL SELECT statement + * @param {String} model Model name + * @param {Object} filter Filter object + * @param {Object} options Options object + * @returns {ParameterizedSQL} Statement object {sql: ..., params: [...]} + */ +SQLConnector.prototype.buildSelect = function(model, filter, options) { + if (!filter.order) { + var idNames = this.idNames(model); + if (idNames && idNames.length) { + filter.order = idNames; + } + } + + var selectStmt = new ParameterizedSQL('SELECT ' + + this.buildColumnNames(model, filter) + + ' FROM ' + this.tableEscaped(model) + ); + + if (filter) { + + if (filter.where) { + var whereStmt = this.buildWhere(model, filter.where); + selectStmt.merge(whereStmt); + } + + if (filter.order) { + selectStmt.merge(this.buildOrderBy(model, filter.order)); + } + + if (filter.limit || filter.skip || filter.offset) { + selectStmt = this.applyPagination( + model, selectStmt, filter); + } + + } + return this.parameterize(selectStmt); +}; + +/** + * Transform the row data into a model data object + * @param {string} model Model name + * @param {object} rowData An object representing the row data from DB + * @returns {object} Model data object + */ +SQLConnector.prototype.fromRow = SQLConnector.prototype.fromDatabase = function(model, rowData) { + if (rowData == null) { + return rowData; + } + var props = this.getModelDefinition(model).properties; + var data = {}; + for (var p in props) { + var columnName = this.column(model, p); + // Load properties from the row + var columnValue = this.fromColumnValue(props[p], rowData[columnName]); + if (columnValue !== undefined) { + data[p] = columnValue; + } + } + return data; +}; + +/** + * Find matching model instances by the filter + * + * Please also note the name `all` is confusing. `Model.find` is to find all + * matching instances while `Model.findById` is to find an instance by id. On + * the other hand, `Connector.prototype.all` implements `Model.find` while + * `Connector.prototype.find` implements `Model.findById` due to the `bad` + * naming convention we inherited from juggling-db. + * + * @param {String} model The model name + * @param {Object} filter The filter + * @param {Function} [cb] The cb function + */ +SQLConnector.prototype.all = function find(model, filter, options, cb) { + var self = this; + // Order by id if no order is specified + filter = filter || {}; + var stmt = this.buildSelect(model, filter, options); + this.execute(stmt.sql, stmt.params, options, function(err, data) { + if (err) { + return cb(err, []); + } + + var objs = data.map(function(obj) { + return self.fromRow(model, obj); + }); + if (filter && filter.include) { + self.getModelDefinition(model).model.include(objs, filter.include, cb); + } else { + cb(null, objs); + } + }); +}; +// Alias to `all`. Juggler checks `all` only. +Connector.defineAliases(SQLConnector.prototype, 'all', ['findAll']); + +/** + * ATM, this method is not used by loopback-datasource-juggler dao, which + * maps `findById` to `find` with a `where` filter that includes the `id` + * instead. + * + * Please also note the name `find` is confusing. `Model.find` is to find all + * matching instances while `Model.findById` is to find an instance by id. On + * the other hand, `Connector.prototype.find` is for `findById` and + * `Connector.prototype.all` is for `find` due the `bad` convention used by + * juggling-db. + * + * Find by id + * @param {String} model The Model name + * @param {*} id The id value + * @param {Object} options The options object + * @param {Function} cb The callback function + * @private + */ +SQLConnector.prototype.find = function(model, id, options, cb) { + if (id == null) { + process.nextTick(function() { + var err = new Error('id value is required'); + if (cb) { + cb(err); + } + }); + return; + } + var where = {}; + var idName = this.idName(model); + where[idName] = id; + + var filter = {limit: 1, offset: 0, order: idName, where: where}; + return this.all(model, filter, options, function(err, results) { + cb(err, (results && results[0]) || null); + }); +}; +// Alias to `find`. Juggler checks `findById` only. +Connector.defineAliases(SQLConnector.prototype, 'find', ['findById']); /** * Count all model instances by the where filter * * @param {String} model The model name - * @param {Function} callback The callback function - * @param {Object} where The where clause - */ -SqlConnector.prototype.count = function count(model, callback, where) { - var self = this; - - var whereClause = buildWhere(self, model, where); - this.queryOne('SELECT count(*) as cnt FROM ' + - this.tableEscaped(model) + ' ' + whereClause, function (err, res) { - if (err) { - return callback(err); - } - callback(err, res && res.cnt); - }); -}; - -/** - * Update attributes for a given model instance - * @param {String} model The model name - * @param {*} id The id value - * @param {Object} data The model data instance containing all properties to be updated + * @param {Object} where The where object + * @param {Object} options The options object * @param {Function} cb The callback function */ -SqlConnector.prototype.updateAttributes = function updateAttributes(model, id, data, cb) { - if (!isIdValuePresent(id, cb)) { - return; +SQLConnector.prototype.count = function(model, where, options, cb) { + if (typeof where === 'function') { + // Backward compatibility for 1.x style signature: + // count(model, cb, where) + var tmp = options; + cb = where; + where = tmp; } - var idName = this.getDataSource(model).idName(model); - delete data[idName]; - var where = {}; - where[idName] = id; - this.updateAll(model, where, data, cb); + + var stmt = new ParameterizedSQL('SELECT count(*) as "cnt" FROM ' + + this.tableEscaped(model)); + stmt = stmt.merge(this.buildWhere(model, where)); + stmt = this.parameterize(stmt); + this.execute(stmt.sql, stmt.params, + function(err, res) { + if (err) { + return cb(err); + } + var c = (res && res[0] && res[0].cnt) || 0; + // Some drivers return count as a string to contain bigint + // See https://github.com/brianc/node-postgres/pull/427 + cb(err, Number(c)); + }); }; /** - * Disconnect from the connector + * Drop the table for the given model from the database + * @param {String} model The model name + * @param {Function} [cb] The callback function */ -SqlConnector.prototype.disconnect = function disconnect() { - // No operation +SQLConnector.prototype.dropTable = function(model, cb) { + this.execute('DROP TABLE IF EXISTS ' + this.tableEscaped(model), cb); +}; + +/** + * Create the table for the given model + * @param {String} model The model name + * @param {Function} [cb] The callback function + */ +SQLConnector.prototype.createTable = function(model, cb) { + var sql = 'CREATE TABLE ' + this.tableEscaped(model) + + ' (\n ' + this.buildColumnDefinitions(model) + '\n)'; + this.execute(sql, cb); }; /** @@ -358,7 +1141,7 @@ SqlConnector.prototype.disconnect = function disconnect() { * if not present, apply to all models defined in the connector * @param {Function} [cb] The callback function */ -SqlConnector.prototype.automigrate = function (models, cb) { +SQLConnector.prototype.automigrate = function(models, cb) { var self = this; if ((!cb) && ('function' === typeof models)) { @@ -376,8 +1159,8 @@ SqlConnector.prototype.automigrate = function (models, cb) { } var invalidModels = models.filter(function(m) { - return !(m in self._models); - }); + return !(m in self._models); + }); if (invalidModels.length) { return process.nextTick(function() { cb(new Error('Cannot migrate models not attached to this datasource: ' + @@ -403,42 +1186,141 @@ SqlConnector.prototype.automigrate = function (models, cb) { }; /** - * Drop the table for the given model from the database - * @param {String} model The model name - * @param {Function} [cb] The callback function + * Serialize an object into JSON string or other primitive types so that it + * can be saved into a RDB column + * @param {Object} obj The object value + * @returns {*} */ -SqlConnector.prototype.dropTable = function (model, cb) { - this.command('DROP TABLE IF EXISTS ' + this.tableEscaped(model), cb); +SQLConnector.prototype.serializeObject = function(obj) { + var val; + if (obj && typeof obj.toJSON === 'function') { + obj = obj.toJSON(); + } + if (typeof obj !== 'string') { + val = JSON.stringify(obj); + } else { + val = obj; + } + return val; +}; + +/*! + * @param obj + */ +SQLConnector.prototype.escapeObject = function(obj) { + var val = this.serializeObject(obj); + return this.escapeValue(val); }; /** - * Create the table for the given model - * @param {String} model The model name - * @param {Function} [cb] The callback function + * The following _abstract_ methods have to be implemented by connectors that + * extend from SQLConnector to reuse the base implementations of CRUD methods + * from SQLConnector */ -SqlConnector.prototype.createTable = function (model, cb) { - this.command('CREATE TABLE ' + this.tableEscaped(model) + - ' (\n ' + this.propertiesSQL(model) + '\n)', cb); +/** + * Converts a model property value into the form required by the + * database column. The result should be one of following forms: + * + * - {sql: "point(?,?)", params:[10,20]} + * - {sql: "'John'", params: []} + * - "John" + * + * @param {Object} propertyDef Model property definition + * @param {*} value Model property value + * @returns {ParameterizedSQL|*} Database column value. + * + */ +SQLConnector.prototype.toColumnValue = function(propertyDef, value) { + throw new Error('toColumnValue() must be implemented by the connector'); }; /** - * Update all instances that match the where clause with the given data - * @param {String} model The model name - * @param {Object} data The property/value object representing changes to be made - * @param {Function} callback The callback function + * Convert the data from database column to model property + * @param {object} propertyDef Model property definition + * @param {*) value Column value + * @returns {*} Model property value */ -SqlConnector.prototype.update = - SqlConnector.prototype.updateAll = function (model, where, data, callback) { - var whereClause = buildWhere(this, model, where); +SQLConnector.prototype.fromColumnValue = function(propertyDef, value) { + throw new Error('fromColumnValue() must be implemented by the connector'); +}; - var sql = 'UPDATE ' + this.tableEscaped(model) + ' SET ' + - this.toFields(model, data) + ' ' + whereClause; +/** + * Escape the name for the underlying database + * @param {String} name The name + * @returns {String} An escaped name for SQL + */ +SQLConnector.prototype.escapeName = function(name) { + throw new Error('escapeName() must be implemented by the connector'); +}; - this.query(sql, function (err, result) { - if (callback) { - callback(err, result); - } - }); - }; +/** + * Escape the name for the underlying database + * @param {String} value The value to be escaped + * @returns {*} An escaped value for SQL + */ +SQLConnector.prototype.escapeValue = function(value) { + throw new Error('escapeValue() must be implemented by the connector'); +}; +/** + * Get the place holder in SQL for identifiers, such as ?? + * @param {String} key Optional key, such as 1 or id + * @returns {String} The place holder + */ +SQLConnector.prototype.getPlaceholderForIdentifier = function(key) { + throw new Error('getPlaceholderForIdentifier() must be implemented by the connector'); +}; + +/** + * Get the place holder in SQL for values, such as :1 or ? + * @param {String} key Optional key, such as 1 or id + * @returns {String} The place holder + */ +SQLConnector.prototype.getPlaceholderForValue = function(key) { + throw new Error('getPlaceholderForValue() must be implemented by the connector'); +}; + +/** + * Build a new SQL statement with pagination support by wrapping the given sql + * @param {String} model The model name + * @param {ParameterizedSQL} stmt The sql statement + * @param {Number} limit The maximum number of records to be fetched + * @param {Number} offset The offset to start fetching records + * @param {String[]} order The sorting criteria + */ +SQLConnector.prototype.applyPagination = function(model, stmt, filter) { + throw new Error('applyPagination() must be implemented by the connector'); +}; + +/** + * Parse the result for SQL UPDATE/DELETE/INSERT for the number of rows + * affected + * @param {String} model Model name + * @param {Object} info Status object + * @returns {Number} Number of rows affected + */ +SQLConnector.prototype.getCountForAffectedRows = function(model, info) { + throw new Error('getCountForAffectedRows() must be implemented by the connector'); +}; + +/** + * Parse the result for SQL INSERT for newly inserted id + * @param {String} model Model name + * @param {Object} info The status object from driver + * @returns {*} The inserted id value + */ +SQLConnector.prototype.getInsertedId = function(model, info) { + throw new Error('getInsertedId() must be implemented by the connector'); +}; + +/** + * Execute a SQL statement with given parameters + * @param {String} sql The SQL statement + * @param {*[]} [params] An array of parameter values + * @param {Object} [options] Options object + * @param {Function} [callback] The callback function + */ +SQLConnector.prototype.executeSQL = function(sql, params, options, callback) { + throw new Error('executeSQL() must be implemented by the connector'); +}; diff --git a/package.json b/package.json index ed1d851..c76f43d 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,13 @@ "url": "https://github.com/strongloop/loopback-connector/blob/master/LICENSE" }, "dependencies": { - "async": "^0.9.0" + "async": "^0.9.0", + "debug": "^2.1.3" }, "devDependencies": { - "chai": "~1.9.2", - "jshint": "^2.6.0", - "loopback-datasource-juggler": "^2.0.0", - "mocha": "^1.19.0" + "chai": "~2.3.0", + "jshint": "^2.7.0", + "loopback-datasource-juggler": "^2.26.3", + "mocha": "^2.2.4" } } diff --git a/test/connectors/test-sql-connector.js b/test/connectors/test-sql-connector.js index 41e0a24..b3a5890 100644 --- a/test/connectors/test-sql-connector.js +++ b/test/connectors/test-sql-connector.js @@ -2,12 +2,12 @@ * A mockup connector that extends SQL connector */ var util = require('util'); -var SqlConnector = require('../../lib/sql'); +var SQLConnector = require('../../lib/sql'); exports.initialize = function initializeDataSource(dataSource, callback) { process.nextTick(function() { - if(callback) { - var connector = new TestConnector(); + if (callback) { + var connector = new TestConnector(dataSource.settings); connector.dataSource = dataSource; dataSource.connector = connector; callback(null, connector); @@ -15,12 +15,65 @@ exports.initialize = function initializeDataSource(dataSource, callback) { }); }; -function TestConnector() { - SqlConnector.apply(this, [].slice.call(arguments)); +function TestConnector(settings) { + SQLConnector.call(this, 'testdb', settings); this._tables = {}; } -util.inherits(TestConnector, SqlConnector); +util.inherits(TestConnector, SQLConnector); + +TestConnector.prototype.escapeName = function(name) { + return '`' + name + '`'; +}; + +TestConnector.prototype.dbName = function(name) { + return name.toUpperCase(); +}; + +TestConnector.prototype.getPlaceholderForValue = function(key) { + return '$' + key; +}; + +TestConnector.prototype.escapeValue = function(value) { + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + return "'" + value + "'"; + } + if (value == null) { + return 'NULL'; + } + if (typeof value === 'object') { + return String(value); + } + return value; +}; + +TestConnector.prototype.toColumnValue = function(prop, val) { + return val; +}; + +TestConnector.prototype._buildLimit = function(model, limit, offset) { + if (isNaN(limit)) { + limit = 0; + } + if (isNaN(offset)) { + offset = 0; + } + if (!limit && !offset) { + return ''; + } + return 'LIMIT ' + (offset ? (offset + ',' + limit) : limit); +}; + +TestConnector.prototype.applyPagination = + function(model, stmt, filter) { + /*jshint unused:false */ + var limitClause = this._buildLimit(model, filter.limit, + filter.offset || filter.skip); + return stmt.merge(limitClause); + }; TestConnector.prototype.dropTable = function(model, cb) { var err; @@ -47,3 +100,7 @@ TestConnector.prototype.createTable = function(model, cb) { cb(err); }); }; + +TestConnector.prototype.executeSQL = function(sql, params, options, callback) { + callback(null, []); +}; diff --git a/test/smoke.test.js b/test/smoke.test.js index 70f742b..cb01c34 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -9,4 +9,22 @@ describe('loopback-connector', function() { it('exports SqlConnector', function() { assert(connector.SqlConnector); }); + + it('exports SQLConnector', function() { + assert(connector.SQLConnector); + }); + + it('creates aliases to Connector.prototype.execute', function() { + assert.equal(connector.Connector.prototype.execute, + connector.Connector.prototype.query); + assert.equal(connector.Connector.prototype.execute, + connector.Connector.prototype.command); + }); + + it('creates aliases to SQLConnector.prototype.execute', function() { + assert.equal(connector.SQLConnector.prototype.execute, + connector.SQLConnector.prototype.query); + assert.equal(connector.SQLConnector.prototype.execute, + connector.SQLConnector.prototype.command); + }); }); diff --git a/test/sql.test.js b/test/sql.test.js new file mode 100644 index 0000000..0b62ddd --- /dev/null +++ b/test/sql.test.js @@ -0,0 +1,310 @@ +var expect = require('chai').expect; +var SQLConnector = require('../lib/sql'); +var ParameterizedSQL = SQLConnector.ParameterizedSQL; +var testConnector = require('./connectors/test-sql-connector'); + +var juggler = require('loopback-datasource-juggler'); +var ds = new juggler.DataSource({ + connector: testConnector, + debug: true +}); +var connector; + +describe('sql connector', function() { + before(function() { + connector = ds.connector; + connector._tables = {}; + connector._models = {}; + ds.createModel('customer', + { + name: { + id: true, + type: String, + testdb: { + column: 'NAME', + dataType: 'VARCHAR', + dataLength: 32 + } + }, vip: { + type: Boolean, + testdb: { + column: 'VIP' + } + }, + address: String + }, + {testdb: {table: 'CUSTOMER'}}); + }); + + it('should map table name', function() { + var table = connector.table('customer'); + expect(table).to.eql('CUSTOMER'); + }); + + it('should map column name', function() { + var column = connector.column('customer', 'name'); + expect(column).to.eql('NAME'); + }); + + it('should find column metadata', function() { + var column = connector.columnMetadata('customer', 'name'); + expect(column).to.eql({ + column: 'NAME', + dataType: 'VARCHAR', + dataLength: 32 + }); + }); + + it('should map property name', function() { + var prop = connector.propertyName('customer', 'NAME'); + expect(prop).to.eql('name'); + }); + + it('should map id column name', function() { + var idCol = connector.idColumn('customer'); + expect(idCol).to.eql('NAME'); + }); + + it('should find escaped id column name', function() { + var idCol = connector.idColumnEscaped('customer'); + expect(idCol).to.eql('`NAME`'); + }); + + it('should find escaped table name', function() { + var table = connector.tableEscaped('customer'); + expect(table).to.eql('`CUSTOMER`'); + }); + + it('should find escaped column name', function() { + var column = connector.columnEscaped('customer', 'vip'); + expect(column).to.eql('`VIP`'); + }); + + it('should convert to escaped id column value', function() { + var column = connector.idColumnValue('customer', 'John'); + expect(column).to.eql('John'); + }); + + it('builds where', function() { + var where = connector.buildWhere('customer', {name: 'John'}); + expect(where.toJSON()).to.eql({ + sql: 'WHERE `NAME`=?', + params: ['John'] + }); + }); + + it('builds where with null', function() { + var where = connector.buildWhere('customer', {name: null}); + expect(where.toJSON()).to.eql({ + sql: 'WHERE `NAME` IS NULL', + params: [] + }); + }); + + it('builds where with inq', function() { + var where = connector.buildWhere('customer', {name: {inq: ['John', 'Mary']}}); + expect(where.toJSON()).to.eql({ + sql: 'WHERE `NAME` IN (?,?)', + params: ['John', 'Mary'] + }); + }); + + it('builds where with or', function() { + var where = connector.buildWhere('customer', + {or: [{name: 'John'}, {name: 'Mary'}]}); + expect(where.toJSON()).to.eql({ + sql: 'WHERE (`NAME`=?) OR (`NAME`=?)', + params: ['John', 'Mary'] + }); + }); + + it('builds where with and', function() { + var where = connector.buildWhere('customer', + {and: [{name: 'John'}, {vip: true}]}); + expect(where.toJSON()).to.eql({ + sql: 'WHERE (`NAME`=?) AND (`VIP`=?)', + params: ['John', true] + }); + }); + + it('builds order by with one field', function() { + var orderBy = connector.buildOrderBy('customer', 'name'); + expect(orderBy).to.eql('ORDER BY `NAME`'); + }); + + it('builds order by with two fields', function() { + var orderBy = connector.buildOrderBy('customer', ['name', 'vip']); + expect(orderBy).to.eql('ORDER BY `NAME`,`VIP`'); + }); + + it('builds order by with two fields and dirs', function() { + var orderBy = connector.buildOrderBy('customer', ['name ASC', 'vip DESC']); + expect(orderBy).to.eql('ORDER BY `NAME` ASC,`VIP` DESC'); + }); + + it('builds fields for columns', function() { + var fields = connector.buildFields('customer', {name: 'John', vip: true}); + expect(fields.names).to.eql(['`NAME`', '`VIP`']); + expect(fields.columnValues[0].toJSON()).to.eql( + {sql: '?', params: ['John']}); + expect(fields.columnValues[1].toJSON()).to.eql( + {sql: '?', params: [true]}); + }); + + it('builds fields for UPDATE without ids', function() { + var fields = connector.buildFieldsForUpdate('customer', + {name: 'John', vip: true}); + expect(fields.toJSON()).to.eql({ + sql: 'SET `VIP`=?', + params: [true] + }); + }); + + it('builds fields for UPDATE with ids', function() { + var fields = connector.buildFieldsForUpdate('customer', + {name: 'John', vip: true}, false); + expect(fields.toJSON()).to.eql({ + sql: 'SET `NAME`=?,`VIP`=?', + params: ['John', true] + }); + }); + + it('builds column names for SELECT', function() { + var cols = connector.buildColumnNames('customer'); + expect(cols).to.eql('`NAME`,`VIP`,`ADDRESS`'); + }); + + it('builds column names with true fields filter for SELECT', function() { + var cols = connector.buildColumnNames('customer', {fields: {name: true}}); + expect(cols).to.eql('`NAME`'); + }); + + it('builds column names with false fields filter for SELECT', function() { + var cols = connector.buildColumnNames('customer', {fields: {name: false}}); + expect(cols).to.eql('`VIP`,`ADDRESS`'); + }); + + it('builds column names with array fields filter for SELECT', function() { + var cols = connector.buildColumnNames('customer', {fields: ['name']}); + expect(cols).to.eql('`NAME`'); + }); + + it('builds DELETE', function() { + var sql = connector.buildDelete('customer', {name: 'John'}); + expect(sql.toJSON()).to.eql({ + sql: 'DELETE FROM `CUSTOMER` WHERE `NAME`=$1', + params: ['John'] + }); + }); + + it('builds UPDATE', function() { + var sql = connector.buildUpdate('customer', {name: 'John'}, {vip: false}); + expect(sql.toJSON()).to.eql({ + sql: 'UPDATE `CUSTOMER` SET `VIP`=$1 WHERE `NAME`=$2', + params: [false, 'John'] + }); + }); + + it('builds SELECT', function() { + var sql = connector.buildSelect('customer', + {order: 'name', limit: 5, where: {name: 'John'}}); + expect(sql.toJSON()).to.eql({ + sql: 'SELECT `NAME`,`VIP`,`ADDRESS` FROM `CUSTOMER`' + + ' WHERE `NAME`=$1 ORDER BY `NAME` LIMIT 5', + params: ['John'] + }); + }); + + it('builds INSERT', function() { + var sql = connector.buildInsert('customer', {name: 'John', vip: true}); + expect(sql.toJSON()).to.eql({ + sql: 'INSERT INTO `CUSTOMER`(`NAME`,`VIP`) VALUES($1,$2)', + params: ['John', true] + }); + }); + + it('normalizes a SQL statement from string', function() { + var sql = 'SELECT * FROM `CUSTOMER`'; + var stmt = new ParameterizedSQL(sql); + expect(stmt.toJSON()).to.eql({sql: sql, params: []}); + }); + + it('normalizes a SQL statement from object without params', function() { + var sql = {sql: 'SELECT * FROM `CUSTOMER`'}; + var stmt = new ParameterizedSQL(sql); + expect(stmt.toJSON()).to.eql({sql: sql.sql, params: []}); + }); + + it('normalizes a SQL statement from object with params', function() { + var sql = + {sql: 'SELECT * FROM `CUSTOMER` WHERE `NAME`=?', params: ['John']}; + var stmt = new ParameterizedSQL(sql); + expect(stmt.toJSON()).to.eql({sql: sql.sql, params: ['John']}); + }); + + it('should throw if the statement is not a string or object', function() { + expect(function() { + /*jshint unused:false */ + var stmt = new ParameterizedSQL(true); + }).to.throw('sql must be a string'); + }); + + it('concats SQL statements', function() { + var stmt1 = {sql: 'SELECT * from `CUSTOMER`'}; + var where = {sql: 'WHERE `NAME`=?', params: ['John']}; + stmt1 = ParameterizedSQL.append(stmt1, where); + expect(stmt1.toJSON()).to.eql( + {sql: 'SELECT * from `CUSTOMER` WHERE `NAME`=?', params: ['John']}); + }); + + it('concats string SQL statements', function() { + var stmt1 = 'SELECT * from `CUSTOMER`'; + var where = {sql: 'WHERE `NAME`=?', params: ['John']}; + stmt1 = ParameterizedSQL.append(stmt1, where); + expect(stmt1.toJSON()).to.eql( + {sql: 'SELECT * from `CUSTOMER` WHERE `NAME`=?', params: ['John']}); + }); + + it('should throw if params does not match placeholders', function() { + expect(function() { + var stmt1 = 'SELECT * from `CUSTOMER`'; + var where = {sql: 'WHERE `NAME`=?', params: ['John', 'Mary']}; + stmt1 = ParameterizedSQL.append(stmt1, where); + }).to.throw('must match the number of params'); + }); + + it('should allow execute(sql, callback)', function(done) { + connector.execute('SELECT * FROM `CUSTOMER`', done); + }); + + it('should allow execute(sql, params, callback)', function(done) { + connector.execute('SELECT * FROM `CUSTOMER` WHERE `NAME`=$1', + ['xyz'], done); + }); + + it('should allow execute(sql, params, options, callback)', function(done) { + connector.execute('SELECT * FROM `CUSTOMER` WHERE `NAME`=$1', + ['xyz'], {transaction: true}, done); + }); + + it('should throw if params is not an array for execute()', function() { + expect(function() { + connector.execute('SELECT * FROM `CUSTOMER`', 'xyz', function() { + }); + }).to.throw('params must be an array'); + }); + + it('should throw if options is not an object for execute()', function() { + expect(function() { + connector.execute('SELECT * FROM `CUSTOMER`', [], 'xyz', function() { + }); + }).to.throw('options must be an object'); + }); + + it('should throw if callback is not a function for execute()', function() { + expect(function() { + connector.execute('SELECT * FROM `CUSTOMER`', [], {}, 'xyz'); + }).to.throw('callback must be a function'); + }); +}); +