diff --git a/lib/datastore/dataset.js b/lib/datastore/dataset.js index d5a8c48f40c..a5958a5bc0d 100644 --- a/lib/datastore/dataset.js +++ b/lib/datastore/dataset.js @@ -129,6 +129,64 @@ Dataset.prototype.key = function(keyConfig) { return new entity.Key(keyConfig); }; +/** + * Register a Kind's schema. You can later refer to the schema to validate an + * entity. See `validateKind` for an example. + * + * @param {string} kind - Name of the kind. + * @param {object} schema - Schema to register to the kind. + * + * @example + * ds.registerKind('Person', { + * name: { + * type: String + * }, + * age: { + * type: datastore.int + * } + * }); + */ +Dataset.prototype.registerKind = function(kind, schema) { + var namespace = this.namespace; + if (util.is(kind, 'object')) { + namespace = kind.namespace; + schema = kind.schema; + kind = kind.name; + } + entity.registerKind(namespace, kind, schema); +}; + +/** + * Validate a registered Kind's schema against an entity. This can be useful + * before saving a new entity to the Datastore. + * + * @param {string} kind - Name of the kind. + * @param {object} ent - Object to validate against the kind's schema. + * @return {booelan} + * + * @example + * // See how we registered the "Person" schema in the `registerKind` example. + * + * ds.validateKind('Person', { + * name: 'Abe Vigoda', + * age: datastore.int(93) + * }); + * // true (`name` is a String, and `age` is an integer) + * + * ds.validateKind('Person', { + * name: 'Abe Vigoda' + * }); + * // false (missing the `age` property) + */ +Dataset.prototype.validateKind = function(kind, ent) { + var namespace = this.namespace; + if (util.is(kind, 'object')) { + namespace = kind.namespace; + ent = kind.entity; + kind = kind.name; + } + return entity.validateKind(namespace, kind, ent); +}; /** * Create a query from the current dataset to query the specified kinds, scoped diff --git a/lib/datastore/entity.js b/lib/datastore/entity.js index 5fe63dd2ad6..4e978510b50 100644 --- a/lib/datastore/entity.js +++ b/lib/datastore/entity.js @@ -21,6 +21,8 @@ 'use strict'; +var util = require('../common/util'); + /** * @type {object} */ @@ -45,14 +47,33 @@ var OP_TO_OPERATOR = { 'HAS_ANCESTOR': 'HAS_ANCESTOR' }; -/** @const {array} A list of native objects. */ +/** @const {object} A mapping of native type to Datastore type. */ +var NATIVE_TO_DATASTORE_TYPE = { + array: 'list_value', + boolean: 'boolean_value', + buffer: 'blob_value', + date: 'timestamp_microseconds_value', + double: 'double_value', + int: 'integer_value', + key: 'key_value', + object: 'entity_value', + string: 'string_value' +}; + +/** @const {array} A list of "native" objects. */ var PRIMITIVE_KINDS = [ Object, Boolean, Number, String, Date, - Buffer + Buffer, + Key, + Int, + Double, + 'key', + 'int', + 'double' ]; /** @const {object} Conversion map for query sign -> order protocol value. */ @@ -316,17 +337,17 @@ module.exports.isKeyComplete = function(key) { * * @example * propertyToValue({ - * booleanValue: false + * boolean_value: false * }); * // false * * propertyToValue({ - * stringValue: 'Hi' + * string_value: 'Hi' * }); * // 'Hi' * * propertyToValue({ - * blobValue: 'aGVsbG8=' + * blob_value: 'aGVsbG8=' * }); * // */ @@ -375,12 +396,12 @@ function propertyToValue(property) { * @example * valueToProperty('Hi'); * // { - * // stringValue: 'Hi' + * // string_value: 'Hi' * // } */ function valueToProperty(v) { var p = {}; - if (v instanceof Boolean || typeof v === 'boolean') { + if (util.is(v, 'boolean')) { p.boolean_value = v; return p; } @@ -392,7 +413,7 @@ function valueToProperty(v) { p.double_value = v.get(); return p; } - if (v instanceof Number || typeof v === 'number') { + if (util.is(v, 'number')) { if (v % 1 === 0) { p.integer_value = v; } else { @@ -400,11 +421,11 @@ function valueToProperty(v) { } return p; } - if (v instanceof Date) { + if (util.is(v, 'date')) { p.timestamp_microseconds_value = v.getTime() * 1000; return p; } - if (v instanceof String || typeof v === 'string') { + if (util.is(v, 'string')) { p.string_value = v; return p; } @@ -412,17 +433,15 @@ function valueToProperty(v) { p.blob_value = v; return p; } - if (v instanceof Array) { - p.list_value = v.map(function(item) { - return valueToProperty(item); - }); + if (util.is(v, 'array')) { + p.list_value = v.map(valueToProperty); return p; } if (v instanceof Key) { p.key_value = keyToKeyProto(v); return p; } - if (v instanceof Object && Object.keys(v).length > 0) { + if (util.is(v, 'object') && Object.keys(v).length > 0) { var property = []; Object.keys(v).forEach(function(k) { property.push({ @@ -452,12 +471,12 @@ function valueToProperty(v) { * }); * // { * // key: null, - * // properties: { + * // property: { * // name: { - * // stringValue: 'Burcu' + * // string_value: 'Burcu' * // }, * // legit: { - * // booleanValue: true + * // boolean_value: true * // } * // } * // } @@ -569,23 +588,19 @@ module.exports.queryToQueryProto = queryToQueryProto; * Register a kind and its field metadata globally. In order to perform CRUD * operations for a kind, you should register it with its field metadata. * - * @private - * - * @todo Validate namespace, kind, and fieldMeta. - * * @param {string} namespace - Namespace of the kind. * @param {string} kind - Name of the kind. * @param {object} fieldMeta - Metadata information about the fields. * * @example * registerKind('namespace', 'Author', { - * name: { kind: String, indexed: false }, - * tags: { kind: String, multi: true }, // an array of string elements. - * favArticles: { kind: KEY, multi: true }, + * name: { type: String, indexed: false }, + * tags: { type: String, multi: true }, // an array of string elements. + * favArticles: { type: 'Key', multi: true }, * contact: { - * kind: { - * telephone: { kind: String }, - * email: { kind: String } + * type: { + * telephone: { type: String }, + * email: { type: String } * } * } * }); @@ -600,14 +615,19 @@ function registerKind(namespace, kind, fieldMeta) { throw new Error('Kinds should match ' + KIND_REGEX); } - Object.keys(fieldMeta).forEach(function(fieldName) { + var schema = Object.keys(fieldMeta).reduce(function(acc, fieldName) { validateField(fieldName, fieldMeta[fieldName]); - }); + acc[fieldName] = { + metadata: fieldMeta[fieldName], + protocol: metadataTypeToProto(fieldMeta[fieldName]) + }; + return acc; + }, {}); if (!entityMeta[namespace]) { entityMeta[namespace] = {}; } - entityMeta[namespace][kind] = fieldMeta; + entityMeta[namespace][kind] = schema; } module.exports.registerKind = registerKind; @@ -615,18 +635,131 @@ module.exports.registerKind = registerKind; /** * Get a Kind object. * - * @private - * * @param {string} namespace - The namespace of the kind. * @param {string} kind - The Datstore Kind. * @return {object} */ function getKind(namespace, kind) { + namespace = namespace || ''; return entityMeta[namespace] && entityMeta[namespace][kind]; } module.exports.getKind = getKind; +/** + * Return a null-valued protocol. Useful for creating a schema, such as when + * registering a Kind. + * + * @private + * + * @param {object} metadataType + * @return {object} + * + * @example + * metadataTypeToProto({ type: String, multi: true }); + * // { + * // list_value: [ + * // string_value: null + * // ] + * // } + */ +function metadataTypeToProto(metadataType) { + if (util.is(metadataType.type, 'object')) { + return { + entity_value: { + property: Object.keys(metadataType.type).map(function(type) { + return { + name: type, + value: metadataTypeToProto(metadataType.type[type]) + }; + }) + } + }; + } + var proto = {}; + var name; + if (metadataType.type.name) { + name = metadataType.type.name.toLowerCase(); + } else { + name = metadataType.type.toLowerCase(); + } + if (metadataType.multi) { + var arrayObj = {}; + arrayObj[NATIVE_TO_DATASTORE_TYPE[name]] = null; + proto.list_value = [ arrayObj ]; + } else { + proto[NATIVE_TO_DATASTORE_TYPE[name]] = null; + } + return proto; +} + +/** + * Validate an entity against a Kind's registered schema. + * + * @param {string} namespace - Namespace. + * @param {string} kind - Name of the kind to compare against. + * @param {object} entity - An entity to compare with. + * @return {boolean) + */ +function validateKind(namespace, kind, entity) { + kind = getKind(namespace, kind); + entity = entityToEntityProto(entity); + var LIST_VALUE = NATIVE_TO_DATASTORE_TYPE.array; + var ENTITY_VALUE = NATIVE_TO_DATASTORE_TYPE.object; + + function getProperty(properties, name) { + return properties.filter(function(entity) { + return entity.name === name; + })[0]; + } + + function getPropertyType(property) { + return Object.keys(property)[0]; + } + + function validateEntityValue(proto, entity) { + return proto[ENTITY_VALUE].property.every(function(item) { + var protoType = getPropertyType(item.value); + var property = getProperty(entity[ENTITY_VALUE].property, item.name); + if (!property) { + return false; + } + var propertyType = getPropertyType(property.value); + if (protoType === ENTITY_VALUE) { + return validateEntityValue(item.value, property.value); + } + return protoType === propertyType; + }); + } + + function validateListValue(proto, entity) { + return proto[LIST_VALUE].every(function(item) { + var itemType = getPropertyType(item); + return entity[LIST_VALUE].every(function(value) { + return getPropertyType(value) === itemType; + }); + }); + } + + return Object.keys(kind).every(function(propertyName) { + var proto = kind[propertyName].protocol; + var protoType = getPropertyType(proto); + var entityProperty = getProperty(entity.property, propertyName); + if (!entityProperty) { + return false; + } + if (protoType === ENTITY_VALUE) { + return validateEntityValue(proto, entityProperty.value); + } + if (protoType === LIST_VALUE) { + return validateListValue(proto, entityProperty.value); + } + return protoType === getPropertyType(entityProperty.value); + }); +} + +module.exports.validateKind = validateKind; + /** * Validate a field. * @@ -634,12 +767,11 @@ module.exports.getKind = getKind; * * @param {string} name - Field name. * @param {object} field - Field metadata object. - * @param {string} field.key - Field key. - * @param {*} field.kind - Field Kind. + * @param {*} field.type - Field data type. * * @example * validateField('title', { - * kind: String + * type: String * }); * // undefined (no errors thrown.) */ @@ -647,16 +779,18 @@ function validateField(name, field) { if (!FIELD_NAME_REGEX.test(name)) { throw new Error('Field name should match ' + FIELD_NAME_REGEX); } - if (!field.kind) { + if (!field.type) { throw new Error('Provide a kind for field ' + name); } - if (typeof field.kind !== 'object' && - PRIMITIVE_KINDS.indexOf(field.kind) === -1) { + if (typeof field.type !== 'object' && + PRIMITIVE_KINDS.indexOf(field.type) === -1 && + field.type.name && + PRIMITIVE_KINDS.indexOf(field.type.name.toLowerCase()) === -1) { throw new Error('Unknown kind for field ' + name); } - if (typeof field.kind === 'object') { - Object.keys(field.key).forEach(function(key) { - validateField(key, field.key[key]); + if (typeof field.type === 'object') { + Object.keys(field.type).forEach(function(type) { + validateField(type, field.type[type]); }); } } diff --git a/lib/datastore/index.js b/lib/datastore/index.js index 2fb207a0a19..a19f8d16ef0 100644 --- a/lib/datastore/index.js +++ b/lib/datastore/index.js @@ -50,7 +50,7 @@ datastore.Dataset = require('./dataset'); * // Create an Integer. * var sevenInteger = gcloud.datastore.int(7); */ -datastore.int = function(value) { +datastore.int = function Int(value) { return new entity.Int(value); }; @@ -63,7 +63,7 @@ datastore.int = function(value) { * // Create a Double. * var threeDouble = gcloud.datastore.double(3.0); */ -datastore.double = function(value) { +datastore.double = function Double(value) { return new entity.Double(value); }; diff --git a/test/datastore/dataset.js b/test/datastore/dataset.js index ba441d6dada..b7520844fd3 100644 --- a/test/datastore/dataset.js +++ b/test/datastore/dataset.js @@ -180,6 +180,40 @@ describe('Dataset', function() { }); }); + describe('schema', function() { + var ds = new datastore.Dataset({ + namespace: 'ns', + projectId: 'test' + }); + var schema = { + name: { + type: String + }, + age: { + type: 'int' + } + }; + + it('should register a kind schema using provided NS', function() { + ds.registerKind('Sample', schema); + }); + + it('should register a kind schema using a provided namespace', function() { + ds.registerKind({ + namespace: 'AnotherNS', + name: 'Sample', + schema: schema + }); + }); + + it('should validate a schema', function() { + assert.strictEqual(ds.validateKind('Sample', { + name: 'Abe Vigoda', + age: datastore.int(93) + }), true); + }); + }); + describe('runInTransaction', function() { var ds; var transaction; diff --git a/test/datastore/entity.js b/test/datastore/entity.js index f3a789b3677..fe8b8a0850a 100644 --- a/test/datastore/entity.js +++ b/test/datastore/entity.js @@ -23,74 +23,56 @@ var entity = require('../../lib/datastore/entity.js'); var datastore = require('../../lib/datastore'); var blogPostMetadata = { - title: { kind: String, indexed: true }, - tags: { kind: String, multi: true, indexed: true }, - publishedAt: { kind: Date }, - author: { kind: Object, indexed: true }, - isDraft: { kind: Boolean, indexed: true } + title: { type: String, indexed: true }, + tags: { type: String, multi: true, indexed: true }, + publishedAt: { type: Date }, + author: { type: Object, indexed: true }, + isDraft: { type: Boolean, indexed: true } }; var entityProto = { - 'property': [{ - 'name': 'linkedTo', - 'value': { - 'key_value': { - 'path_element': [{ - 'kind': 'Kind', - 'name': 'another' - }] + property: [ + { + name: 'linkedTo', + value: { + key_value: { + path_element: [{ kind: 'Kind', name: 'another' }] } - } - }, { - 'name': 'name', - 'value': { - 'string_value': 'Some name' - } - }, { - 'name': 'flagged', - 'value': { - 'boolean_value': false } - }, { - 'name': 'count', - 'value': { - 'integer_value': 5 + }, + { + name: 'name', + value: { + string_value: 'Some name' } - }, { - 'name': 'total', - 'value': { - 'double_value': 7.8 + }, + { + name: 'flagged', + value: { + boolean_value: false } - }, { - 'name': 'author', - 'value': { - 'entity_value': { - 'property': [{ - 'name': 'name', - 'value': { - 'string_value': 'Burcu Dogan' - } - }] - }, - 'indexed': false - } - }, { - 'name': 'list', - 'value': { - 'list_value': [{ - 'integer_value': 6 - }, { - 'boolean_value': false - }] + }, + { name: 'count', value: { integer_value: 5 } }, + { name: 'total', value: { double_value: 7.8 } }, + { + name: 'author', + value: { + entity_value: { + property: [{ name: 'name', value: { string_value: 'Burcu Dogan' } }] + }, + indexed: false } - }] + }, + { + name: 'list', + value: { list_value: [{ integer_value: 6 }, { boolean_value: false }] } + } + ] }; var queryFilterProto = { projection: [], - kind: [{ - name: 'Kind1' - }], + kind: [{ name: 'Kind1' }], filter: { composite_filter: { filter: [ @@ -106,9 +88,7 @@ var queryFilterProto = { property: { name: '__key__' }, operator: 'HAS_ANCESTOR', value: { - key_value: { - path_element: [{ kind: 'Kind2', name: 'somename' }] - } + key_value: { path_element: [{ kind: 'Kind2', name: 'somename' }] } } } } @@ -121,23 +101,19 @@ var queryFilterProto = { }; describe('registerKind', function() { - it('should be able to register valid field metadata', function(done) { + it('should be able to register valid field metadata', function() { entity.registerKind('namespace', 'kind', blogPostMetadata); - done(); }); - it('should set the namespace to "" if zero value or null', function(done) { + it('should set the namespace to "" if zero value or null', function() { entity.registerKind(null, 'kind', blogPostMetadata); - var meta = entity.getKind('', 'kind'); - assert.strictEqual(meta, blogPostMetadata); - done(); + entity.getKind('', 'kind'); }); - it('should throw an exception if an invalid kind', function(done) { + it('should throw an exception if an invalid kind', function() { assert.throws(function() { entity.registerKind(null, '000', blogPostMetadata); }, /Kinds should match/); - done(); }); }); @@ -280,9 +256,7 @@ describe('entityFromEntityProto', function() { }); describe('entityToEntityProto', function() { - it( - 'should support boolean, integer, double, string, entity and list values', - function(done) { + it('should support bool, int, double, str, entity & [] vals', function(done) { var now = new Date(); var proto = entity.entityToEntityProto({ name: 'Burcu', @@ -317,7 +291,6 @@ describe('entityToEntityProto', function() { assert.equal(entityValue.property[1].value.string_value, 'value2'); done(); }); - }); describe('queryToQueryProto', function() { @@ -331,3 +304,103 @@ describe('queryToQueryProto', function() { done(); }); }); + +describe('Kind schema', function() { + var schema = { + name: { + type: String, + indexed: false + }, + tags: { + type: String, + multi: true + }, + favArticles: { + type: 'Key', + multi: true + }, + contact: { + type: { + telephone: { type: String }, + email: { type: String } + } + } + }; + + entity.registerKind(null, 'Sample', schema); + + it('should store the protocol on the schema', function() { + var sample = entity.getKind(null, 'Sample'); + Object.keys(sample).forEach(function(property) { + assert.equal(typeof sample[property].protocol, 'object'); + }); + }); + + it('should validate a Kind schema', function() { + assert.strictEqual( + entity.validateKind(null, 'Sample', { + name: 'Name', + tags: ['a', 'b', 'c'], + favArticles: [ + new entity.Key({ path: ['Article', 1] }), + new entity.Key({ path: ['Article', 2] }) + ], + contact: { + telephone: '5551234567', + email: 'email@theemailstore.com' + } + }), true); + assert.strictEqual( + entity.validateKind(null, 'Sample', { + name: 'Name', + tags: ['a', 'b', 'c'], + favArticles: [ + new entity.Key({ path: ['Article', 1] }), + new entity.Key({ path: ['Article', 2] }) + ], + contact: { + telephone: '5551234567', + email: 3 // Should be string_value + } + }), false); + assert.strictEqual( + entity.validateKind(null, 'Sample', { + name: 'Name', + tags: ['a', 1], // Should all be string_value. + favArticles: [ + new entity.Key({ path: ['Article', 1] }), + new entity.Key({ path: ['Article', 2] }) + ], + contact: { + telephone: '5551234567', + email: 'email@theemailstore.com' + } + }), false); + assert.strictEqual( + entity.validateKind(null, 'Sample', { + // name: 'Name', // (missing property) + tags: ['a', 'b', 'c'], + favArticles: [ + new entity.Key({ path: ['Article', 1] }), + new entity.Key({ path: ['Article', 2] }) + ], + contact: { + telephone: '5551234567', + email: 'email@theemailstore.com' + } + }), false); + assert.strictEqual( + entity.validateKind(null, 'Sample', { + name: 'Name', + tags: ['a', 'b', 'c'], + favArticles: [ + new entity.Key({ path: ['Article', 1] }), + new entity.Key({ path: ['Article', 2] }) + ], + contact: { + // telephone: '5551234567', // (missing embedded entity property) + email: 'email@theemailstore.com' + } + }), false); + }); +}); diff --git a/test/datastore/index.js b/test/datastore/index.js index a71fa6e3913..6531bd9190a 100644 --- a/test/datastore/index.js +++ b/test/datastore/index.js @@ -42,15 +42,27 @@ describe('Datastore', function() { assert.equal(typeof datastore.Dataset, 'function'); }); - it('should expose Int builder', function() { - var anInt = 7; - datastore.int(anInt); - assert.equal(entity.intCalledWith, anInt); + describe('int', function() { + it('should expose Int builder', function() { + var anInt = 7; + datastore.int(anInt); + assert.equal(entity.intCalledWith, anInt); + }); + + it('should name the Int builder function', function() { + assert.equal(datastore.int.name, 'Int'); + }); }); - it('should expose Double builder', function() { - var aDouble = 7.0; - datastore.double(aDouble); - assert.equal(entity.doubleCalledWith, aDouble); + describe('double', function() { + it('should expose Double builder', function() { + var aDouble = 7.0; + datastore.double(aDouble); + assert.equal(entity.doubleCalledWith, aDouble); + }); + + it('should name the Double builder function', function() { + assert.equal(datastore.double.name, 'Double'); + }); }); });