diff --git a/lib/model.js b/lib/model.js index 4e1a3161..27b0382c 100644 --- a/lib/model.js +++ b/lib/model.js @@ -104,6 +104,117 @@ Model.new = function(name, schema, options, thinky) { util.changeProto(doc, new Document(model, options)); + /* + * The strategy of this proxy/handler is to link + * the supplied object (obj) with a supplied schema + * for the purposes of supporting custom, non-virtual + * functions attached to sub-objects of documents. + * It does this hooking the basic 'set' function + * of the target object and altering the prototype + * of the value being set if custom functions need + * to be applied. + */ + function installHandlerForSchema(obj, schema) { + var handler = { + schema: schema, + set: function(target, property, value, receiver) { + /* + * There are two scenarios that need to be supported + * - Parent object has a singular sub-doc set by + * parent.property = value + * - Parent object has an array of sub-docs altered by + * parent.property[i] = value + * parent.property.push(value) + * Arrays also interact with their 'length' property + * automatically, so we only want to target numeric + * properties of arrays + */ + var propertySchema = null; + if (Array.isArray(target)) { + /* + * Only hook actual elements and not other properties + * or functions of arrays + */ + if (!isNaN(parseInt(property))) { + propertySchema = this.schema._schema; + } + } else { + propertySchema = this.schema[property]; + } + var assignmentVal = null; + /* + * Only do work if the value is not null or undefined + */ + if ((value !== null) && (value !== undefined)) { + /* + * If this value is an array, we don't want to change it's + * prototype, but we do want to install a proxy/handler to + * capture any objects that get inserted into it. We only + * want to do this though if the objects require custom + * methods. + * + * If this value is an object and the schema defines that + * the object has custom functions, then we need to change + * it's prototype and install a handler + * + * If this value doesn't have custom methods, namely + * _methods.length === 0 or undefined or null + * but does define a deeper schema, then we still need + * to install a handler as there may potentially be deeper + * sub-docs with custom functions + * + * If none of that is the case, pass through the value assignment + */ + if (thinky.type.isArray(propertySchema)) { + var elementSchema = propertySchema._schema; + if ( (elementSchema !== undefined) + && (elementSchema !== null) + && (elementSchema._methods !== undefined) + && (Object.keys(elementSchema._methods).length > 0)) { + assignmentVal = installHandlerForSchema(value, propertySchema); + } else { + assignmentVal = value; + } + } else if ((propertySchema !== undefined) + && (propertySchema !== null) + && (propertySchema._methods !== undefined) + && (Object.keys(propertySchema._methods).length > 0)) { + util.changeProto(value, propertySchema._methods); + assignmentVal = installHandlerForSchema(value, propertySchema._schema); + } else if ((propertySchema !== undefined) + && (propertySchema !== null) + && (propertySchema._schema !== undefined) + && (propertySchema._schema !== null ) + && (thinky.type.isObject(propertySchema._schema))){ + assignmentVal = installHandlerForSchema(value, propertySchema._schema); + } else { + assignmentVal = value; + } + } else { + assignmentVal = value; + } + target[property] = assignmentVal; + return true; + } + } + var proxy = new Proxy(obj, handler); + /* + * Given that we've just altered the 'set' logic for the object, + * we need to perform set on all the keys to ensure that any + * existing values are altered correctly + */ + Object.keys(proxy).forEach((key) => { + if ((proxy[key] !== undefined) && (proxy[key] !== null)) { + proxy[key] = proxy[key]; + } + }); + return proxy; + } + + if ('_usesCustomFunctions' in doc._getModel()._schema) { + doc = installHandlerForSchema(doc, doc._getModel()._schema._schema); + } + // Create joins document. We do it here because `options` are easily available util.loopKeys(proto._joins, function(joins, key) { if (doc[key] != null) { @@ -165,6 +276,13 @@ Model.new = function(name, schema, options, thinky) { model.__proto__ = proto; + + if (model._schema._methods !== undefined) { + Object.keys(model._schema._methods).forEach(function(key) { + model.define(key, model._schema._methods[key]); + }); + } + if (options.init !== false) { // Setup the model's table. model.tableReady().then(); diff --git a/lib/schema.js b/lib/schema.js index 252f8d71..3647e863 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -117,6 +117,9 @@ module.exports.generateDefault = generateDefault; function parse(schema, prefix, options, model) { var result; + var reservedFunctionKeys = ['default', 'validator'] + var dataTypeFunctions = [String, Number, Boolean, Date, Buffer, Object, Array]; + if ((prefix === '') && (type.isObject(schema) === false) && (util.isPlainObject(schema) === false)) { throw new Errors.ValidationError("The schema must be a plain object.") } @@ -164,7 +167,14 @@ function parse(schema, prefix, options, model) { result = type.object().options(options).validator(schema.validator); if (schema.default !== undefined) { result.default(schema.default); } util.loopKeys(schema.schema, function(_schema, key) { - result.setKey(key, parse(_schema[key], prefix+"["+key+"]", options)); + if ((typeof _schema[key] === 'function') && (dataTypeFunctions.indexOf(_schema[key]) === -1)) { + if (result._methods === undefined) { + result._methods = {}; + } + result._methods[key] = _schema[key]; + } else { + result.setKey(key, parse(_schema[key], prefix+"["+key+"]", options)); + } }) if (prefix === '') { result._setModel(model) @@ -233,7 +243,31 @@ function parse(schema, prefix, options, model) { else { result = type.object().options(options); util.loopKeys(schema, function(_schema, key) { - result.setKey(key, parse(_schema[key], prefix+"["+key+"]", options)); + if ((typeof _schema[key] === 'function') && (dataTypeFunctions.indexOf(_schema[key]) === -1)) { + if (result._methods === undefined) { + result._methods = {}; + } + result._methods[key] = _schema[key]; + //ignore for top level object since it leverages define() + if (prefix !== '') { + result._usesCustomFunctions = true; + } + } else { + var schemaObj = parse(_schema[key], prefix+"["+key+"]", options); + result.setKey(key, schemaObj); + /* + * If a sub-object of a property uses custom functions, ensure + * that it bubbles up to the parent + */ + if (schemaObj !== undefined) { + if ('_usesCustomFunctions' in schemaObj) { + result._usesCustomFunctions = schemaObj._usesCustomFunctions; + } else if ((schemaObj._schema !== undefined) + && ('_usesCustomFunctions' in schemaObj._schema)) { + result._usesCustomFunctions = schemaObj._schema._usesCustomFunctions; + } + } + } }) if (prefix === '') { result._setModel(model) diff --git a/test/sub_doc_functions.js b/test/sub_doc_functions.js new file mode 100644 index 00000000..646af53d --- /dev/null +++ b/test/sub_doc_functions.js @@ -0,0 +1,217 @@ +var config = require(__dirname+'/../config.js'); + +var thinky = require(__dirname+'/../lib/thinky.js')(config); +var r = thinky.r; +var type = thinky.type; + +var util = require(__dirname+'/util.js'); +var assert = require('assert'); +var Promise = require('bluebird'); +var Errors = thinky.Errors; + + +var modelNameSet = {}; +modelNameSet[util.s8()] = true; +modelNameSet[util.s8()] = true; + +var modelNames = Object.keys(modelNameSet); + +var cleanTables = function(done) { + var promises = []; + var name; + for(var name in modelNameSet) { + promises.push(r.table(name).delete().run()); + } + Promise.settle(promises).error(function () {/*ignore*/}).finally(function() { + // Add the links table + for(var model in thinky.models) { + modelNameSet[model] = true; + } + modelNames = Object.keys(modelNameSet); + thinky._clean(); + done(); + }); +} + + + +describe('Sub Document Functions', function(){ + + afterEach(cleanTables); + + var basicModelOneSpec = { + id: String, + name: String, + email: type.string(), + address: { + street: String, + geoCode: function() { + return this.street; + } + }, + topLevelFunc: function() { + return "topLevel"; + } + } + var basicModelOne = null; + + it('can create a basic model', function(done){ + var testModelOne = thinky.createModel("TestModelOne", basicModelOneSpec); + assert.equal(type.isObject(testModelOne._schema._schema.address), true); + assert.equal(typeof testModelOne._schema._schema.address._methods, 'object'); + assert.equal(Object.keys(testModelOne._schema._schema.address._methods).length, 1); + assert.equal(testModelOne._schema._schema.address._methods.geoCode, basicModelOneSpec.address.geoCode); + basicModelOne = testModelOne; + done(); + }); + + var basicModelTwoSpec = { + id: String, + name: String, + email: type.string(), + addresses: [{ + street: String, + geoCode: function() { + return this.street; + } + }] + } + var basicModelTwo = null; + + it('can create a basic model with object array', function(done){ + var testModelTwo = thinky.createModel("TestModelTwo", basicModelTwoSpec); + assert.equal(type.isArray(testModelTwo._schema._schema.addresses), true); + assert.equal(typeof testModelTwo._schema._schema.addresses._schema._methods, 'object'); + assert.equal(Object.keys(testModelTwo._schema._schema.addresses._schema._methods).length, 1); + assert.equal(testModelTwo._schema._schema.addresses._schema._methods.geoCode, basicModelTwoSpec.addresses[0].geoCode); + basicModelTwo = testModelTwo; + done(); + }); + + it('can instantiate basic objects correctly', function(done){ + var one = new basicModelOne({ + id: 'db15e340-1ebf-4c24-871b-c383330cef7f', + name: 'Tester', + email: 'tester@test.com', + address: { + street: "123 Fake Street" + } + }); + assert.equal(typeof one.address, 'object'); + assert.equal(one.topLevelFunc(), 'topLevel'); + assert.equal(one.address.geoCode(), "123 Fake Street"); + var two = new basicModelTwo({ + id: '7777b2c1-26ac-45e0-a0f2-f11e613265b9', + name: 'Tester2', + email: 'tester2@test.com', + addresses: [{ + street: "456 Fake Street" + }] + }); + assert.equal(Array.isArray(two.addresses), true); + assert.equal(two.addresses.length, 1); + assert.equal(two.addresses[0].geoCode(), "456 Fake Street"); + done(); + }); + + var basicModelThreeSpec = { + id: String, + name: String, + email: type.string(), + address: { + street: String, + geoCode: function() { + return this.street; + }, + zipCode: { + num: Number, + lookup: function() { + return "North Pole"; + }, + source: { + name: String, + rep: function() { + return 24; + } + } + } + } + } + var basicModelThree = null; + + it('can build nested object models', function(done){ + var testModelThree = thinky.createModel("TestModelThree", basicModelThreeSpec); + basicModelThree = testModelThree; + done(); + }); + + it('can create nested object models', function(done){ + var three = new basicModelThree({ + id: 'e9c8111e-a09a-4268-b25e-e42583113058', + name: 'Tester3', + email: 'tester3@test.com', + address: { + street: "123 Fake Street", + zipCode: { + num: 4000, + source: { + name: "Test Source" + } + } + } + }); + assert.equal(three.address.zipCode.lookup(), "North Pole"); + assert.equal(three.address.zipCode.source.rep(), 24); + three.save().then(function(result) { + assert.equal(three.isSaved(), true); + basicModelThree.get(three.id).then(function(dbResult) { + assert.equal(dbResult.address.zipCode.lookup(), "North Pole"); + assert.equal(dbResult.address.zipCode.source.rep(), 24); + done(); + }).error(done); + }).error(done); + }); + + var basicModelFourSpec = { + id: String, + name: String, + email: type.string(), + address: { + street: String, + zipCode: { + num: Number, + lookup: function() { + return "North Pole"; + }, + source: { + name: String, + rep: function() { + return 24; + } + } + } + } + } + + it('can create object models with custom functions not on top level', function(done){ + var testModelFour = thinky.createModel("TestModelFour", basicModelFourSpec); + var four = new basicModelThree({ + id: 'e9c8111e-a09a-4268-b25e-e42583113058', + name: 'Tester3', + email: 'tester3@test.com', + address: { + street: "123 Fake Street", + zipCode: { + num: 4000, + source: { + name: "Test Source" + } + } + } + }); + assert.equal(four.address.street, "123 Fake Street"); + assert.equal(four.address.zipCode.lookup(), "North Pole"); + done(); + }); + +}); \ No newline at end of file