diff --git a/.gitignore b/.gitignore index d3f11de..4d9c59d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ # https://help.github.com/articles/ignoring-files # Example .gitignore files: https://github.com/github/gitignore /bower_components/ -/node_modules/ \ No newline at end of file +/node_modules/ +/pe diff --git a/index.coffee b/index.coffee new file mode 100644 index 0000000..90e5250 --- /dev/null +++ b/index.coffee @@ -0,0 +1 @@ +module.exports = require "./lib" diff --git a/lib/index.coffee b/lib/index.coffee new file mode 100644 index 0000000..b5d74c9 --- /dev/null +++ b/lib/index.coffee @@ -0,0 +1,97 @@ +Model = require "./model" +knex = require "knex" + +class YAORM + constructor: (options) -> + @_models = {} + + if options.knex? + @knex = options.knex + else if options.hostname? + @knex = knex({ + client: switch options.driver + # Some aliases... + when "postgres", "postgresql" then "pg" + when "mysql" then "mysql2" + else options.driver + connection: + host: options.hostname + user: options.username + password: options.password + database: options.database + charset: options.charset ? "utf8" + debug: options.debug ? false + }) + else if options.knexfile? + knexfile = require "knexfile" + + if knexfile.connection? + # Basic configuration + @knex = knex(knexfile) + else + # Environment configuration + if not options.environment? + # FIXME: Error + null + @knex = knex(knexfile[options.environment]) + else + # FIXME: Error + + _registerModel: (model) -> + model._YAORM = this + @_models[model.name] = model + return this + + _createRelation: (type, options) -> + options.type = type + return options + + loadModel: (modelPath) -> + # We use the existing model as a prototype, so that we don't run into conflicts if two different YAORM instances were to use the same loaded model. + baseModel = require(modelPath) + model = Object.create(baseModel) + @_registerModel(model) + + defineModel: (modelName, options) -> + model = new Model(modelName, options) + @_registerModel(model) + + model: (modelName) -> + return @_models[modelName] + + express: -> + return (req, res, next) -> + null + next() + + hasOne: (modelName, options = {}) -> + if options.foreignKey? + options.remoteKey = options.foreignKey + delete options.foreignKey + + options.modelName = modelName + @_createRelation "hasOne", options + + hasMany: (modelName, options = {}) -> + if options.foreignKey? + options.remoteKey = options.foreignKey + delete options.foreignKey + + options.modelName = modelName + @_createRelation "hasMany", options + + belongsTo: (modelName, options = {}) -> + if options.foreignKey? + options.localKey = options.foreignKey + delete options.foreignKey + + options.modelName = modelName + @_createRelation "belongsTo", options + +exportMethod = (options) -> + return new YAORM(options) + +exportMethod.defineModel = (modelName, options) -> + return new Model(modelName, options) + +module.exports = exportMethod diff --git a/lib/model.coffee b/lib/model.coffee new file mode 100644 index 0000000..a8ea511 --- /dev/null +++ b/lib/model.coffee @@ -0,0 +1,188 @@ +Promise = require "bluebird" +util = require "util" + +Record = require "./record" + +module.exports = class Model + constructor: (@name, @options) -> + # FIXME: Validation! tableName + @_isInstance = false + @options.idAttribute ?= "id" + + _getInstance: -> + if @_isInstance + return this + else + instance = Object.create(this) + instance._isInstance = true + instance._queryBuilder = @_YAORM.knex(@options.tableName) + return instance + + _fromQuery: (qbFunc) -> + instance = @_getInstance() + qbFunc(instance._queryBuilder) + return instance + + _where: (whereStatements) -> + @_fromQuery (queryBuilder) -> + queryBuilder.where whereStatements + + _all: -> + # We don't need to add anything to the query here, since we want all the records. + @_getInstance() + + _createResultHandler: (options = {}) -> + return (rows) => + self = this + + Promise.map rows, (row) => + # FIXME: Can this be done with a JOIN, perhaps? + record = self._createRecord() + record.isNew = false + record._setData(row) + + if not options.relations? + options.relations = [] + else if not util.isArray(options.relations) + options.relations = [options.relations] + + record._loadRelations(options.relations ? [], row) + .then (rows) -> + if (options.single ? false) + if rows.length > 0 + return rows[0] + else + # FIXME: Error + else + if rows.length > 0 or (options.required ? true) == false + return rows + else + # FIXME: Error + + _createResultHandlerSingle: (options = {}) -> + options.single = true + @_createResultHandler(options) + + _createResultHandlerCount: (options = {}) -> + return (rows) => + return rows[0].CNT + + _createRecord: -> + record = new Record() + record._setModel(this) + return record + + _populateRecord: (record, data) -> + record._setData(data) + + _getRelations: (relations, data) -> + Promise.try => + relationKeys = Object.keys(relations) + + Promise.map relationKeys, (attribute) => + # FIXME: Shallow-clone these options? Immutability etc. + options = relations[attribute] + + switch options.type + when "hasOne" then @_getHasOne(options.modelName, options, data) + when "hasMany" then @_getHasMany(options.modelName, options, data) + when "belongsTo" then @_getBelongsTo(options.modelName, options, data) + .reduce ((obj, remoteRecord, i) -> + obj[relationKeys[i]] = remoteRecord + return obj + ), {} + + _getSimpleRelation: (modelName, options, data) -> + Promise.try => + remoteModel = @_YAORM.model(modelName) + + switch options.type + when "hasOne", "hasMany" then options.localKey ?= remoteModel.options.idAttribute + when "belongsTo" then options.remoteKey ?= remoteModel.options.idAttribute + + whereStatements = {} + whereStatements[options.remoteKey] = data[options.localKey] + options.query ?= (->) + + queryBuilder = remoteModel + ._where whereStatements + ._fromQuery(options.query) + .query() + + if (options.single ? false) + queryBuilder + .limit 1 + .then remoteModel._createResultHandlerSingle(options) + else + queryBuilder + .then remoteModel._createResultHandler(options) + + # The logic regarding which is the localKey and which is the remoteKey, is handled in the YAORM instance. + _getHasOne: (modelName, options, data) -> + options.single = true + @_getSimpleRelation(modelName, options, data) + + _getHasMany: (modelName, options, data) -> + @_getSimpleRelation(modelName, options, data) + + _getBelongsTo: (modelName, options, data) -> + options.single = true + @_getSimpleRelation(modelName, options, data) + + create: (data) -> + record = @_createRecord() + record.isNew = true + return record + + query: -> + return @_queryBuilder + + find: (id, options = {}) -> + whereStatements = {} + whereStatements[@options.idAttribute] = id + @getOneWhere(whereStatements, options) + + getOneWhere: (whereStatements, options = {}) -> + @_where(whereStatements) + .query() + .limit(1) + .then(@_createResultHandlerSingle(options)) + + getAllWhere: (whereStatements, options = {}) -> + @_where(whereStatements) + .query() + .then(@_createResultHandler(options)) + + countWhere: (whereStatements, options = {}) -> + @_where(whereStatements) + .query() + .count("#{@options.idAttribute} as CNT") + .then(@_createResultHandlerCount(options)) + + getOneFromQuery: (qbFunc, options = {}) -> + @_fromQuery(qbFunc) + .query() + .limit(1) + .then(@_createResultHandlerSingle(options)) + + getAllFromQuery: (qbFunc, options = {}) -> + @_fromQuery(qbFunc) + .query() + .then(@_createResultHandler(options)) + + countFromQuery: (qbFunc, options = {}) -> + @_fromQuery(qbFunc) + .query() + .count("#{@options.idAttribute} as CNT") + .then(@_createResultHandlerCount(options)) + + getAll: (options = {}) -> + @_all() + .query() + .then(@_createResultHandler(options)) + + countAll: (options = {}) -> + @_all() + .query() + .count("#{@options.idAttribute} as CNT") + .then(@_createResultHandlerCount(options)) diff --git a/lib/record.coffee b/lib/record.coffee new file mode 100644 index 0000000..15480bc --- /dev/null +++ b/lib/record.coffee @@ -0,0 +1,66 @@ +Promise = require "bluebird" + +_shallowClone = (obj) -> + newObject = {} + for key, value of obj + newObject[key] = value + return newObject + +module.exports = class Record + constructor: -> + @_data = {} + @_savedData = {} + @_changedData = {} + + _setModel: (model) -> + self = this + + @_model = model + + if model.options.columns? + model.options.columns.forEach (column) => + Object.defineProperty this, column, + get: -> self.get column + set: (value) -> self.set column, value + + _setData: (data) -> + # We might need a deep clone here? + @_data = _shallowClone(data) + @_savedData = _shallowClone(data) + + _loadRelations: (relations, data) -> + Promise.map relations, (relation) => + {key: relation, value: @_model.options.relations[relation]} + .reduce ((obj, relationData) => + obj[relationData.key] = relationData.value + return obj + ), {} + .then (relations) => + @_model._getRelations(relations, data) + .then (relations) => + for attribute, record of relations + this[attribute] = record + + return this + + _saveAttributes: (attributes) -> + null # do stuff + + # Upon success... + @_savedData = @_data + @_changedData = {} + + get: (attribute) -> + return @_data[attribute] + + set: (attribute, value) -> + @_data[attribute] = value + @_changedData[attribute] = value + + save: -> + # This only saves the changed attributes - it is almost always what you want. + @_saveAttributes(@_changedData) + + saveAll: -> + # This saves *all* the attributes as they are currently set in the object - even if something else has changed them in the database in the meantime. You probably don't need this. + @_saveAttributes(@_data) diff --git a/package.json b/package.json new file mode 100644 index 0000000..29c53df --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "yaorm", + "version": "1.0.0", + "description": "Yet Another ORM based on Knex.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git@git.cryto.net:projects/joepie91/node-yaorm" + }, + "keywords": [ + "orm", + "knex", + "database", + "mysql", + "postgresql", + "sqlite" + ], + "author": "Sven Slootweg", + "license": "WTFPL", + "dependencies": { + "bluebird": "^2.9.22", + "knex": "^0.7.6" + }, + "devDependencies": { + "pretty-error": "^1.1.1" + } +} diff --git a/test.coffee b/test.coffee new file mode 100644 index 0000000..da50b9b --- /dev/null +++ b/test.coffee @@ -0,0 +1,38 @@ +require("pretty-error").start().skipPackage("bluebird", "coffee-script").skipNodeFiles() + +Promise = require "bluebird" +#Promise.longStackTraces() + +yaorm = require("./")({ + driver: "pg" + hostname: "localhost" + username: "postgres" + database: "team" +}) + +yaorm.defineModel "User", + tableName: "users" + columns: ["id", "username", "display_name", "email_address", "hash", "external_identifier", "external_authentication", "created_at", "updated_at", "activated", "activation_key"] + relations: + "projects": yaorm.hasMany("Project", foreignKey: "owner_id") + +yaorm.defineModel "Project", + tableName: "projects" + columns: ["id", "owner_id", "public", "name", "slug", "description", "created_at", "updated_at", "default_permissions"] + relations: + "owner": yaorm.belongsTo("User", foreignKey: "owner_id") + +Promise.try -> + yaorm.model("Project").getAll(relations: "owner") +.map (project) -> [project.name, project.owner.display_name] +.then (projects) -> + console.log projects +.catch (err) -> + console.log err.stack + +### TODO: +* Nested relations +* More complex relations (many-to-many?) +* Save (UPDATE + INSERT) +* Deep save (incl. nested relations) +###