diff --git a/package-lock.json b/package-lock.json index 717164c..fbae483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@sodacitylabs/boring-framework", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -811,6 +811,11 @@ "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", "integrity": "sha1-1oDu7yX4zZGtUz9bAe7UjmTK9oM=" }, + "bluebird": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.1.tgz", + "integrity": "sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg==" + }, "body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", @@ -4491,6 +4496,29 @@ "isobject": "^3.0.1" } }, + "objection": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/objection/-/objection-1.6.11.tgz", + "integrity": "sha512-/W6iR6+YvFg1U4k5DyX1MrY+xqodDM8AAOU1J0b3HlptsNw8V3uDHjZgTi1cFPPe5+ZeTTMvhIFhNiUP6+nqYQ==", + "requires": { + "ajv": "^6.10.0", + "bluebird": "^3.5.5", + "lodash": "^4.17.11" + }, + "dependencies": { + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + } + } + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", diff --git a/package.json b/package.json index 654be1c..8065002 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,14 @@ "fastify": "2.10.0", "fastify-static": "2.5.0", "jest": "24.9.0", + "knex": "0.19.1", "lodash": "4.17.15", "maildev": "1.1.0", "nodemailer": "6.3.0", "nodemailer-html-to-text": "3.0.0", "nodemailer-ses-transport": "1.5.1", "nodemailer-smtp-transport": "2.7.4", + "objection": "1.6.11", "pluralize": "7.0.0", "request": "2.88.0", "request-promise-native": "1.0.7", @@ -80,9 +82,9 @@ "coverageDirectory": "/coverage", "collectCoverageFrom": [ "src/core/**/*.js*", + "!src/core/index.js", "!src/core/Database.js", "!src/core/ActiveRecord.js", - "!src/core/ActiveRecordCache.js", "!src/core/Mailer.js", "!src/core/RouterV2.js", "!src/core/RequestController.js", diff --git a/src/cli/generate/model.js b/src/cli/generate/model.js index 5d355eb..f2e449d 100644 --- a/src/cli/generate/model.js +++ b/src/cli/generate/model.js @@ -62,16 +62,15 @@ module.exports = async function(dir, name, attrs) { let belongsTo = []; + const tableName = NounHelper.toPluralResource(name); + const modelName = NounHelper.getSingularForm(name); + fs.writeFileSync( migrationFile, ` exports.up = async function(knex) { - await knex.schema.dropTableIfExists('${NounHelper.toPluralResource( - name - )}'); - await knex.schema.createTable('${NounHelper.toPluralResource( - name - )}', (table) => { + await knex.schema.dropTableIfExists('${tableName}'); + await knex.schema.createTable('${tableName}', (table) => { table.bigIncrements('id').primary(); ${attrs.reduce((acc, curr) => { if (curr.type !== "references") { @@ -79,10 +78,12 @@ module.exports = async function(dir, name, attrs) { } if (curr.type === "references") { - belongsTo.push(`${NounHelper.toPluralResource(curr.name)}`); + belongsTo.push(`${curr.name}`); + acc += `table.bigInteger('${NounHelper.toSingularResource( curr.name )}_id').notNullable();\n`; + acc += `table.foreign("${NounHelper.toSingularResource( curr.name )}_id").references("id").inTable("${NounHelper.toPluralResource( @@ -105,25 +106,20 @@ module.exports = async function(dir, name, attrs) { "utf8" ); + const belongsToRelations = + belongsTo.length && _buildBelongsToRelation(modelName, belongsTo); + fs.writeFileSync( modelFile, ` const Boring = require('@sodacitylabs/boring-framework'); const ActiveRecord = Boring.Model.ActiveRecord; + ${(belongsTo.length && + belongsTo.reduce((acc, curr) => `const ${curr} = require('./${curr}')`), + "")} - module.exports = class ${NounHelper.getSingularForm( - name - )} extends ActiveRecord { - constructor(attrs) { - super(attrs); - } - ${ - belongsTo.length - ? `get belongsTo() { - return ${JSON.stringify(belongsTo)}; - }` - : "get belongsTo() { return []; }" - } + module.exports = class ${modelName} extends ActiveRecord { + ${belongsTo.length && belongsToRelations} }; `, "utf8" @@ -143,6 +139,8 @@ module.exports = async function(dir, name, attrs) { "utf8" ); + // TODO: write the inverse relation mapping to the parent? + spawnSync(`${dir}/node_modules/.bin/prettier "${migrationFile}" --write`, { stdio: `inherit`, shell: true, @@ -159,3 +157,38 @@ module.exports = async function(dir, name, attrs) { cwd: dir }); }; + +/** + * + * @param {*} sourceModel the source model ie. "Comment" + * @param {*} relatedModels array of related models ie. ["BlogPost"] + */ +function _buildBelongsToRelation(sourceModel, relatedModels) { + const relationsAsString = relatedModels.reduce((acc, curr) => { + const camelizedRelatedModelName = NounHelper.getCamelCaseSingularForm(curr); + const sourceTableName = NounHelper.toPluralResource(sourceModel); + const relatedIdColumnName = `${NounHelper.toSingularResource(curr)}_id`; + const relatedTableName = NounHelper.toPluralResource(curr); + + acc += ` + ${camelizedRelatedModelName}: { + relation: ActiveRecord.BelongsToOneRelation, + modelClass: ${curr}, + join: { + from: '${sourceTableName}.${relatedIdColumnName}', + to: '${relatedTableName}.id' + } + } + `; + + return acc; + }, ""); + + return ` + static get relationMappings() { + return { + ${relationsAsString} + }; + }; + `; +} diff --git a/src/core/ActiveRecord.js b/src/core/ActiveRecord.js index e82cd3f..209aad8 100644 --- a/src/core/ActiveRecord.js +++ b/src/core/ActiveRecord.js @@ -1,305 +1,38 @@ -const db = require("./Database"); const NounHelper = require("./helpers/NounHelper"); -const ActiveRecordCache = require("./ActiveRecordCache"); +const { Model, knexSnakeCaseMappers } = require("objection"); +const Knex = require("knex"); +const dbInfo = require(`${process.cwd()}/config`).db; -// todo: move this to a helper -function mapColumnsToSnake(columns) { - return Object.keys(columns).map(k => { - return { - snake: k, - camel: k - .split("_") - .map((v, i) => (i > 0 ? `${v[0].toUpperCase()}${v.substring(1)}` : v)) - .join("") - }; - }); -} - -module.exports = class ActiveRecord { - constructor(attrs) { - var self = this; - - Object.keys(attrs).forEach(k => { - self[k] = attrs[k]; - }); - } - - static get modelName() { - return NounHelper.getSingularForm(this.name); - } +Model.knex(Knex(Object.assign(dbInfo, knexSnakeCaseMappers(), {}))); +module.exports = class ActiveRecord extends Model { static get tableName() { return NounHelper.toPluralResource(this.name); } - static async all() { - try { - const Model = ActiveRecordCache.Model.find(this.modelName); - const columns = await ActiveRecordCache.Columns.find(this.tableName); - const rows = await db - .connection() - .select() - .from(this.tableName) - .orderBy("created_at", "asc") - .catch(err => { - console.error(`Error caught: ${err.message}`); - }); - - if (!rows || !rows.length) { - return []; - } - - const cases = mapColumnsToSnake(columns); - const models = rows.map(r => { - const attrs = Object.keys(r).reduce((acc, curr) => { - const column = cases.filter(c => c.snake === curr)[0]; - - acc[column.camel] = r[curr]; - - return acc; - }, {}); - - return new Model(attrs); - }); - - return models; - } catch (ex) { - console.error(`Exception in ${this.modelName}.all :: ${ex.message}`); - return null; - } - } - - static async create(attrs) { - try { - const Model = ActiveRecordCache.Model.find(this.modelName); - const columns = await ActiveRecordCache.Columns.find(this.tableName); - const cases = mapColumnsToSnake(columns); - const toCreate = Object.keys(attrs).reduce((acc, curr) => { - const validColumn = cases.filter(c => c.camel === curr); - - if (validColumn.length) { - acc[validColumn[0].snake] = attrs[curr]; - } - - return acc; - }, {}); - - await db - .connection() - .insert(toCreate) - .into(this.tableName); - - return new Model(attrs); - } catch (ex) { - console.error(`Error in ${this.modelName}.create :: ${ex.message}`); - return null; - } - } - - async destroy() { - try { - await db - .connection() - .table(this.constructor.tableName) - .where("id", this.id) - .del(); - - return true; - } catch (ex) { - console.error( - `Error in ${this.constructor.modelName}.destroy :: ${ex.message}` - ); - return false; - } + static all() { + return this.query(); } - static async find(id) { - try { - const Model = ActiveRecordCache.Model.find(this.modelName); - const row = await db - .connection() - .select() - .from(this.tableName) - .where("id", id) - .first() - .catch(err => { - console.error(`Error caught: ${err.message}`); - }); - - if (!row) { - return null; - } - - const cases = mapColumnsToSnake(row); - const attrs = Object.keys(row).reduce((acc, curr) => { - const validColumn = cases.filter(c => c.snake === curr); - - if (validColumn.length) { - acc[validColumn[0].camel] = row[curr]; - } - - return acc; - }, {}); - - return new Model(attrs); - } catch (ex) { - console.error(`Exception in ${this.modelName}.find :: ${ex.message}`); - return null; - } + static create(attrs) { + return this.query().insert(attrs); } - static async findBy(attrs) { - try { - const Model = ActiveRecordCache.Model.find(this.modelName); - let findAttrs = {}; - - Object.keys(attrs).forEach(a => { - const chars = a.split(""); - const snake = []; - - chars.forEach(c => { - if (c === c.toUpperCase()) { - snake.push("_"); - } - - snake.push(c.toLowerCase()); - }); - - findAttrs[snake.join("")] = attrs[a]; - }); - - const columns = await ActiveRecordCache.Columns.find(this.tableName); - const rows = await db - .connection() - .select() - .from(this.tableName) - .where(findAttrs) - .catch(err => { - console.error(`Error caught: ${err.message}`); - }); - - if (!rows || !rows.length) { - return []; - } - - const cases = mapColumnsToSnake(columns); - const models = rows.map(r => { - const attrs = Object.keys(r).reduce((acc, curr) => { - const column = cases.filter(c => c.snake === curr)[0]; - - acc[column.camel] = r[curr]; - - return acc; - }, {}); - - return new Model(attrs); - }); - - return models; - } catch (ex) { - console.error(`Exception in ${this.modelName}.findBy :: ${ex.message}`); - return null; - } + static destroy(id) { + return this.query().deleteById(id); } - static new(attrs) { - const Model = ActiveRecordCache.Model.find(this.modelName); - - return new Model(attrs); + static find(id) { + return this.query().findById(id); } - async save() { - try { - const columns = await ActiveRecordCache.Columns.find( - this.constructor.tableName - ); - const cases = mapColumnsToSnake(columns); - const toSave = Object.keys(this).reduce((acc, curr) => { - const validColumn = cases.filter(c => c.camel === curr); - - if (validColumn.length) { - acc[validColumn[0].snake] = this[curr]; - } - - return acc; - }, {}); - - if (this.id) { - const row = await db - .connection() - .select() - .from(this.constructor.tableName) - .where("id", this.id) - .first() - .catch(err => { - console.log(`Error caught: ${err.message}`); - }); - - if (row && row.length) { - await db - .connection() - .where("id", this.id) - .update(toSave) - .into(this.constructor.tableName); - } else { - return false; - } - } else { - const result = await db - .connection() - .returning(["id"]) - .insert(toSave) - .into(this.constructor.tableName); - - if (typeof result[0] === "object") { - this.id = result[0].id; - } else { - this.id = result[0]; - } - } - - return true; - } catch (ex) { - console.error( - `Error in ${this.constructor.modelName}.save :: ${ex.message}` - ); - return false; - } + static findBy(attrs) { + return this.query().where(attrs); } - async update(args) { - try { - const columns = await ActiveRecordCache.Columns.find( - this.constructor.tableName - ); - const cases = mapColumnsToSnake(columns); - const current = Object.keys(this).reduce((acc, curr) => { - if (curr === "id") { - return acc; - } - - const validColumn = cases.filter(c => c.camel === curr); - - if (validColumn.length) { - acc[validColumn[0].snake] = this[curr]; - } - - return acc; - }, {}); - const toSave = Object.assign({}, current, args); - - await db - .connection() - .where("id", this.id) - .update(toSave) - .into(this.constructor.tableName); - - return true; - } catch (ex) { - console.error( - `Error in ${this.constructor.modelName}.update :: ${ex.message}` - ); - return false; - } + static update(args) { + return this.query() + .findById(this.id) + .patch(args); } }; diff --git a/src/core/ActiveRecordCache.js b/src/core/ActiveRecordCache.js deleted file mode 100644 index c3d38b8..0000000 --- a/src/core/ActiveRecordCache.js +++ /dev/null @@ -1,41 +0,0 @@ -const cwd = process.cwd(); -const db = require("./Database"); - -let columnsSemaphore = false; - -function ActiveRecordCache() { - let ModelCache = {}; - let ColumnsCache = {}; - - return { - Model: { - find: model => { - if (!ModelCache[model]) { - ModelCache[model] = require(`${cwd}/app/models/${model}`); - } - - return ModelCache[model]; - } - }, - Columns: { - find: async table => { - if (!ColumnsCache[table] && !columnsSemaphore) { - columnsSemaphore = true; - - const columns = await db - .connection() - .table(table) - .columnInfo(); - - if (!ColumnsCache[table]) { - ColumnsCache[table] = columns; // eslint-disable-line require-atomic-updates - } - } - - return ColumnsCache[table]; - } - } - }; -} - -module.exports = new ActiveRecordCache(); diff --git a/src/core/helpers/NounHelper.js b/src/core/helpers/NounHelper.js index 4d36157..a557459 100644 --- a/src/core/helpers/NounHelper.js +++ b/src/core/helpers/NounHelper.js @@ -1,4 +1,5 @@ const pluralize = require("pluralize"); +const _ = require("lodash"); module.exports = class NounHelper { /** @@ -23,6 +24,15 @@ module.exports = class NounHelper { return pluralize.plural(val); } + /** + * @description given a noun, convert to its' singular form in camelCase + * @example BlogPost returns blogPost + * @param {*} val the noun + */ + static getCamelCaseSingularForm(val) { + return _.camelCase(pluralize.singular(val)); + } + /** * @description given a noun, convert to its' singular form * @example BlogPosts returns BlogPost diff --git a/test/core/ActiveRecord.test.js b/test/core/ActiveRecord.test.js deleted file mode 100644 index 2e5e31e..0000000 --- a/test/core/ActiveRecord.test.js +++ /dev/null @@ -1,21 +0,0 @@ -const ActiveRecord = require("../../src/core/ActiveRecord"); - -test("returns singular form for model name", () => { - class BlogPost extends ActiveRecord { - constructor() { - super(); - } - } - - expect(BlogPost.modelName).toBe("BlogPost"); -}); - -test("returns plural form for table name", () => { - class BlogPost extends ActiveRecord { - constructor() { - super(); - } - } - - expect(BlogPost.tableName).toBe("blog_posts"); -}); diff --git a/test/core/index.test.js b/test/core/index.test.js deleted file mode 100644 index 1d42955..0000000 --- a/test/core/index.test.js +++ /dev/null @@ -1,17 +0,0 @@ -const Boring = require("../../src/core"); - -test("exposes the expected API", () => { - const keys = Object.keys(Boring); - expect(keys).toHaveLength(3); - expect(keys).toContain("Controller"); - expect(keys).toContain("Mailer"); - expect(keys).toContain("Model"); - - const controllerKeys = Object.keys(Boring["Controller"]); - expect(controllerKeys).toHaveLength(1); - expect(controllerKeys).toContain("RequestController"); - - const modelKeys = Object.keys(Boring["Model"]); - expect(modelKeys).toHaveLength(1); - expect(modelKeys).toContain("ActiveRecord"); -}); diff --git a/test/helpers/NounHelper.test.js b/test/helpers/NounHelper.test.js index 04298da..0a0cc89 100644 --- a/test/helpers/NounHelper.test.js +++ b/test/helpers/NounHelper.test.js @@ -23,3 +23,7 @@ test("converts BlogPost to blog_posts", () => { test("converts BlogPosts to blog_posts", () => { expect(NounHelper.toPluralResource("BlogPost")).toBe("blog_posts"); }); + +test("converts BlogPost to blogPost", () => { + expect(NounHelper.getCamelCaseSingularForm("BlogPost")).toBe("blogPost"); +});