├── generators ├── migrations │ ├── changeEnumColumn │ │ ├── attributes.hbs │ │ ├── template.hbs │ │ └── index.ts │ ├── addColumns │ │ ├── get-row-defaults.hbs │ │ ├── template.hbs │ │ └── index.ts │ ├── dropTable │ │ ├── template.hbs │ │ └── index.ts │ ├── createTable │ │ ├── template.hbs │ │ └── index.ts │ ├── uuidNonNull │ │ ├── template.hbs │ │ └── index.ts │ ├── removeColumns │ │ ├── template.hbs │ │ └── index.ts │ ├── addEnumValues │ │ ├── template.hbs │ │ └── index.ts │ ├── makeColumnNonNull │ │ ├── template.hbs │ │ └── index.ts │ ├── makeColumnUnique │ │ ├── template.hbs │ │ └── index.ts │ ├── removeEnumValues │ │ ├── template.hbs │ │ └── index.ts │ ├── cascadeWithParent │ │ ├── template.hbs │ │ └── index.ts │ ├── removeNonNullColumn │ │ ├── template.hbs │ │ └── index.ts │ ├── renameEnum │ │ ├── template.hbs │ │ └── index.ts │ ├── addCascadeWithParent │ │ ├── template.hbs │ │ └── index.ts │ ├── makeColumnAllowNull │ │ ├── template.hbs │ │ └── index.ts │ ├── renameIndex │ │ ├── template.hbs │ │ └── index.ts │ ├── renameTable │ │ ├── template.hbs │ │ └── index.ts │ ├── renameColumn │ │ ├── template.hbs │ │ └── index.ts │ ├── renameConstraint │ │ ├── template.hbs │ │ └── index.ts │ ├── changeOnDelete │ │ ├── template.hbs │ │ └── index.ts │ ├── addUniqueConstraintIndex │ │ ├── template.hbs │ │ └── index.ts │ ├── removeUniqueConstraintIndex │ │ ├── template.hbs │ │ └── index.ts │ ├── rename-enum-value │ │ ├── template.hbs │ │ └── index.ts │ ├── addIndex │ │ ├── template.hbs │ │ └── index.ts │ ├── removeIndex │ │ ├── template.hbs │ │ └── index.ts │ ├── renameS3Files │ │ ├── template.hbs │ │ └── index.ts │ ├── bulkInsert │ │ ├── insert.hbs │ │ ├── template.hbs │ │ └── index.ts │ ├── bulkDelete │ │ ├── template.hbs │ │ ├── delete.hbs │ │ └── index.ts │ ├── changeColumn │ │ ├── template.hbs │ │ └── index.ts │ ├── custom │ │ └── index.ts │ └── index.ts ├── migrationTypes │ ├── tableColumnsMigration │ │ ├── definition.hbs │ │ ├── column.hbs │ │ ├── columns.hbs │ │ ├── constraint.hbs │ │ ├── template.hbs │ │ └── regexs.ts │ ├── dbMigration │ │ ├── header.hbs │ │ ├── template.hbs │ │ ├── actions.ts │ │ └── index.ts │ ├── tableColumnMigration │ │ ├── template.hbs │ │ ├── typeDefs.ts │ │ └── index.ts │ ├── tablesMigration │ │ ├── tables.hbs │ │ └── index.ts │ ├── changeEnumAttributes │ │ ├── template.hbs │ │ ├── lib.ts │ │ └── index.ts │ ├── tableMigration │ │ └── index.ts │ ├── tableAssociationColumn │ │ ├── index.ts │ │ └── lib.ts │ ├── tablesColumnMigration │ │ └── index.ts │ ├── index.ts │ └── alterEnum │ │ └── index.ts ├── logger.ts ├── generators │ ├── migration-type-generator │ │ ├── template.hbs │ │ └── index.ts │ ├── migration │ │ ├── template.hbs │ │ └── index.ts │ └── migration-type │ │ ├── index.ts │ │ └── template.hbs └── index.ts ├── .prettierignore ├── src ├── models │ ├── migration │ │ ├── index.ts │ │ ├── definition.ts │ │ └── hooks.ts │ ├── migrationLock │ │ ├── index.ts │ │ └── definition.ts │ └── index.ts ├── views │ ├── error.hbs │ ├── success.hbs │ ├── index.hbs │ └── index.ts ├── classes │ ├── index.ts │ └── WildebeestDb.ts ├── utils │ ├── mkEnum.ts │ ├── sleepPromise.ts │ ├── pascalCase.ts │ ├── getBackoffTime.ts │ ├── createAttributes.ts │ ├── escapeJson.ts │ ├── isEnum.ts │ ├── createHooks.ts │ ├── clearSchema.ts │ ├── freshIndexes.ts │ ├── getNextNumber.ts │ ├── logSection.ts │ ├── tableExists.ts │ ├── reverseMigrator.ts │ ├── invert.ts │ ├── getKeys.ts │ ├── getForeignKeyName.ts │ ├── expectedColumnNames.ts │ ├── getAssociationColumnName.ts │ ├── listColumns.ts │ ├── dropEnum.ts │ ├── restoreFromDump.ts │ ├── indexConstraints.ts │ ├── getAssociationAttribute.ts │ ├── listIndexNames.ts │ ├── columnAllowsNull.ts │ ├── createUmzugLogger.ts │ ├── listEnumAttributes.ts │ ├── getColumnDefault.ts │ ├── writeSchema.ts │ ├── apply.ts │ ├── createIndex.ts │ ├── inferTableReference.ts │ ├── getAssociationsByModelName.ts │ ├── listConstraintNames.ts │ ├── renameS3File.ts │ ├── createAssociationApply.ts │ ├── getNumberedList.ts │ ├── setAssociations.ts │ ├── addTableColumnConstraint.ts │ └── migrateEnumValues.ts ├── migrationTypes │ ├── dropTable.ts │ ├── removeIndex.ts │ ├── removeColumns.ts │ ├── makeColumnAllowNull.ts │ ├── skip.ts │ ├── makeColumnNotUnique.ts │ ├── removeEnumValues.ts │ ├── removeUniqueConstraintIndex.ts │ ├── init.ts │ ├── uuidNonNull.ts │ ├── custom.ts │ ├── renameIndex.ts │ ├── addIndex.ts │ ├── renameEnum.ts │ ├── makeColumnUnique.ts │ ├── index.ts │ ├── cascadeWithParent.ts │ ├── addCascadeWithParent.ts │ └── renameConstraint.ts ├── index.ts ├── checks │ ├── index.ts │ ├── matchingBelongsTo.ts │ ├── allowNullConstraint.ts │ ├── extraneousTables.ts │ ├── model.ts │ ├── columnDefinitions.ts │ ├── uniqueConstraint.ts │ ├── belongsToAssociation.ts │ ├── columnType.ts │ ├── joinBelongsTo.ts │ ├── associationConfig.ts │ └── columnDefinition.ts ├── routes.ts ├── enums.ts └── Logger.ts ├── .prettierrc ├── .eslintignore ├── generateMixins ├── helpers │ ├── pascalCase.ts │ ├── getKeys.ts │ ├── indexPaths.ts │ ├── index.ts │ ├── getCompilerOptions.ts │ ├── setConfigDefaults.ts │ ├── determineModelToExtend.ts │ ├── createBase.ts │ ├── associationDefinitionToSections.ts │ ├── calculateMixinAttributes.ts │ └── serializeAssociationType.ts ├── Handlebars.ts ├── ChangeCase.ts ├── base.hbs └── index.ts ├── .editorconfig ├── tsconfig.json ├── .vscode └── settings.json ├── .pre-commit-config.yaml ├── tslint.json ├── .travis.yml ├── fix_absolute_imports.js ├── package.json └── .eslintrc.js /generators/migrations/changeEnumColumn/attributes.hbs: -------------------------------------------------------------------------------- 1 | {{{ attributes }}} -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnsMigration/definition.hbs: -------------------------------------------------------------------------------- 1 | {{{ definition }}} 2 | -------------------------------------------------------------------------------- /generators/migrations/addColumns/get-row-defaults.hbs: -------------------------------------------------------------------------------- 1 | getRowDefaults: {{{ getRowDefaults }}}, 2 | -------------------------------------------------------------------------------- /generators/logger.ts: -------------------------------------------------------------------------------- 1 | import Logger from '@wildebeest/Logger'; 2 | 3 | export default new Logger(); 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.hbs 2 | .eslintignore 3 | .editorconfig 4 | .gitignore 5 | yarn.lock 6 | .prettierignore 7 | -------------------------------------------------------------------------------- /generators/migrationTypes/dbMigration/header.hbs: -------------------------------------------------------------------------------- 1 | /** 2 | * {{#if comment}}{{{ comment }}}{{else}}TODO{{/if}} 3 | */ 4 | 5 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnMigration/template.hbs: -------------------------------------------------------------------------------- 1 | tableName: '{{ modelTableName }}', 2 | columnName: '{{ columnName }}', -------------------------------------------------------------------------------- /src/models/migration/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A database model for a Migration request. 3 | */ 4 | 5 | export { default } from './Migration'; 6 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnsMigration/column.hbs: -------------------------------------------------------------------------------- 1 | // {{{ comment }}} 2 | {{ columnName }}: { 3 | {{> tableColumnsMigrationDefinition }} 4 | }, 5 | -------------------------------------------------------------------------------- /src/models/migrationLock/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A database model for a MigrationLock request. 3 | */ 4 | 5 | export { default } from './MigrationLock'; 6 | -------------------------------------------------------------------------------- /src/views/error.hbs: -------------------------------------------------------------------------------- 1 |

Migration Failure :(

2 | 3 |

{{ message }}

4 |
5 |
6 |

Back

7 | -------------------------------------------------------------------------------- /src/views/success.hbs: -------------------------------------------------------------------------------- 1 |

Migration Successful!

2 | 3 | 4 |

{{message}}

5 |
6 |
7 |

Back

8 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnsMigration/columns.hbs: -------------------------------------------------------------------------------- 1 | getColumns: ({ DataTypes }) => ({ 2 | {{#each columns}} 3 | {{> tableColumnsMigrationColumn }} 4 | {{/each}} 5 | }), 6 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnsMigration/constraint.hbs: -------------------------------------------------------------------------------- 1 | {{#if tableName }}{{ curly true }} columnName: '{{ columnName }}', tableName: '{{ tableName }}' {{ curly false }}{{else}}'{{ columnName }}'{{/if}} -------------------------------------------------------------------------------- /generators/migrationTypes/tablesMigration/tables.hbs: -------------------------------------------------------------------------------- 1 | tableName: {{#if (listLen tables)}}{{#listComma tables }}'{{ modelTableName }}'{{/listComma}}{{else}}{{#each tables}}'{{ modelTableName }}'{{/each}}{{/if}}, -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "arrowParens": "always" 9 | } 10 | -------------------------------------------------------------------------------- /generators/migrations/dropTable/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { dropTable } from '@wildebeest'; 4 | 5 | module.exports = dropTable({ 6 | {{> tableColumnsMigration }} 7 | }); 8 | -------------------------------------------------------------------------------- /generators/migrationTypes/changeEnumAttributes/template.hbs: -------------------------------------------------------------------------------- 1 | name: '{{ enumName }}', 2 | attributes: [{{#listComma attributes }}'{{ name }}'{{/listComma}}], 3 | tableName: '{{ tableName }}', 4 | columnName: '{{ columnName }}', -------------------------------------------------------------------------------- /generators/migrations/addColumns/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { addColumns } from '@wildebeest'; 4 | 5 | module.exports = addColumns({ 6 | {{> tableColumnsMigration }} 7 | }); 8 | -------------------------------------------------------------------------------- /generators/migrations/createTable/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { createTable } from '@wildebeest'; 4 | 5 | module.exports = createTable({ 6 | {{> tableColumnsMigration }} 7 | }); 8 | -------------------------------------------------------------------------------- /generators/migrations/uuidNonNull/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { uuidNonNull } from '@wildebeest'; 4 | 5 | module.exports = uuidNonNull({ 6 | {{> tableColumnMigration }} 7 | }); 8 | -------------------------------------------------------------------------------- /generators/migrations/removeColumns/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { removeColumns } from '@wildebeest'; 4 | 5 | module.exports = removeColumns({ 6 | {{> tableColumnsMigration }} 7 | }); 8 | -------------------------------------------------------------------------------- /generators/migrations/addEnumValues/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { addEnumValues } from '@wildebeest'; 4 | 5 | module.exports = addEnumValues({ 6 | {{> changeEnumAttributes}} 7 | 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/makeColumnNonNull/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { makeColumnNonNull } from '@wildebeest'; 4 | 5 | module.exports = makeColumnNonNull({ 6 | {{> tableColumnMigration}} 7 | 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/makeColumnUnique/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { makeColumnUnique } from '@wildebeest'; 4 | 5 | module.exports = makeColumnUnique({ 6 | {{> tableColumnMigration }} 7 | 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/removeEnumValues/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { removeEnumValues } from '@wildebeest'; 4 | 5 | module.exports = removeEnumValues({ 6 | {{> changeEnumAttributes }} 7 | 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/cascadeWithParent/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { cascadeWithParent } from '@wildebeest'; 4 | 5 | module.exports = cascadeWithParent({ 6 | {{> tableColumnMigration }} 7 | 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/removeNonNullColumn/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { removeNonNullColumn } from '@wildebeest'; 4 | 5 | module.exports = removeNonNullColumn({ 6 | {{> tableColumnMigration }} 7 | }); 8 | -------------------------------------------------------------------------------- /generators/migrations/renameEnum/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { renameEnum } from '@wildebeest'; 4 | 5 | module.exports = renameEnum({ 6 | oldName: '{{ oldName }}', 7 | newName: '{{ newName }}', 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/addCascadeWithParent/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { addCascadeWithParent } from '@wildebeest'; 4 | 5 | module.exports = addCascadeWithParent({ 6 | {{> tableColumnMigration }} 7 | 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/makeColumnAllowNull/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { makeColumnAllowNull } from '@wildebeest'; 4 | 5 | module.exports = makeColumnAllowNull({ 6 | {{> tableColumnMigration }} 7 | 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/renameIndex/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { renameIndex } from '@wildebeest'; 4 | 5 | module.exports = renameIndex({ 6 | oldName: '{{ oldName }}', 7 | newName: '{{ newName }}', 8 | }); 9 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | // local 2 | import Migration from './migration'; 3 | import MigrationLock from './migrationLock'; 4 | 5 | /** 6 | * Database models needed to run migrations 7 | */ 8 | export default { 9 | Migration, 10 | MigrationLock, 11 | }; 12 | -------------------------------------------------------------------------------- /src/classes/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class definitions for wildebeest 3 | */ 4 | 5 | // local 6 | export { default as Wildebeest } from './Wildebeest'; 7 | export { default as WildebeestDb } from './WildebeestDb'; 8 | export { default as WildebeestModel } from './WildebeestModel'; 9 | -------------------------------------------------------------------------------- /generators/migrations/renameTable/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { renameTable } from '@wildebeest'; 4 | 5 | module.exports = renameTable({ 6 | oldName: '{{ oldName }}', 7 | newName: '{{ newName }}', 8 | renameConstraints: true, 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/mkEnum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make an enum compatible with types 3 | * 4 | * @param x - The enum 5 | * @returns The enum type enforced 6 | */ 7 | export default function mkEnum< 8 | T extends { [index: string]: U }, 9 | U extends string 10 | >(x: T): T { 11 | return x; 12 | } 13 | -------------------------------------------------------------------------------- /generators/migrations/renameColumn/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { renameColumn } from '@wildebeest'; 4 | 5 | module.exports = renameColumn({ 6 | tableName: '{{ modelTableName }}', 7 | oldName: '{{ oldName }}', 8 | newName: '{{ newName }}', 9 | }); 10 | -------------------------------------------------------------------------------- /generators/migrationTypes/dbMigration/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | export default { 3 | up: (migrator, { queryInterface, DataTypes }) => { 4 | throw new Error('TODO'); 5 | }, 6 | down: (migrator, { queryInterface, DataTypes }) => { 7 | throw new Error('TODO'); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /generators/migrations/renameConstraint/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { renameConstraint } from '@wildebeest'; 4 | 5 | module.exports = renameConstraint({ 6 | tableName: '{{ modelTableName }}', 7 | oldName: '{{ oldName }}', 8 | newName: '{{ newName }}', 9 | }); 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .git/* 3 | .github/* 4 | **/docs/* 5 | **/node_modules/* 6 | **/build/* 7 | **/dist/* 8 | **/builds/* 9 | **/coverage/* 10 | **/deprecated/* 11 | **/.storybook-dist/* 12 | **/cypress/* 13 | **/docs/* 14 | **/Internal-Documentation/* 15 | *.testjs 16 | *.testts 17 | *__snapshots__/* 18 | -------------------------------------------------------------------------------- /generators/migrations/changeOnDelete/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { changeOnDelete } from '@wildebeest'; 4 | 5 | module.exports = changeOnDelete({ 6 | {{> tableColumnMigration }} 7 | 8 | oldOnDelete: '{{ oldOnDelete }}', 9 | newOnDelete: '{{ newOnDelete }}', 10 | }); 11 | -------------------------------------------------------------------------------- /generators/migrations/addUniqueConstraintIndex/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { addUniqueConstraintIndex } from '@wildebeest'; 4 | 5 | module.exports = addUniqueConstraintIndex({ 6 | tableName: '{{ modelTableName }}', 7 | fields: [{{#listComma columns}}'{{ columnName }}'{{/listComma}}], 8 | }); 9 | -------------------------------------------------------------------------------- /generators/migrations/removeUniqueConstraintIndex/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { removeUniqueConstraintIndex } from '@wildebeest'; 4 | 5 | module.exports = removeUniqueConstraintIndex({ 6 | tableName: '{{ modelTableName }}', 7 | fields: [{{#listComma columns}}'{{ columnName }}'{{/listComma}}], 8 | }); 9 | -------------------------------------------------------------------------------- /src/utils/sleepPromise.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sleep in a promise 3 | * 4 | * @param sleepTime - The time to sleep 5 | * @returns Resolves promise 6 | */ 7 | export default function sleepPromise(sleepTime: number): Promise { 8 | return new Promise((resolve) => 9 | setTimeout(() => resolve(sleepTime), sleepTime), 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnsMigration/template.hbs: -------------------------------------------------------------------------------- 1 | tableName: '{{ modelTableName }}', 2 | {{> tableColumnsMigrationColumns }} 3 | {{#if getRowDefaults}} 4 | {{> addColumnsGetRowDefaults }} 5 | {{/if}} 6 | {{#if (listLen constraints)}} 7 | constraints: [{{#listComma constraints}}{{> tableColumnsMigrationConstraint }}{{/listComma}}], 8 | {{/if}} -------------------------------------------------------------------------------- /generators/migrations/createTable/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a new db table 3 | */ 4 | export default { 5 | configure: ({ modelTableName, model }) => ({ 6 | name: `create-table-${modelTableName}`, 7 | comment: `Create the ${model} table.`, 8 | }), 9 | description: 'Create a new db table', 10 | type: 'tableColumnsMigration', 11 | }; 12 | -------------------------------------------------------------------------------- /generators/migrations/dropTable/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Drop one of the tables 3 | */ 4 | export default { 5 | configure: ({ modelTableName, model }) => ({ 6 | name: `drop-table-${modelTableName}`, 7 | comment: `Drop the ${model} table.`, 8 | }), 9 | description: 'Drop one of the tables', 10 | suggestOnly: true, 11 | type: 'tableColumnsMigration', 12 | }; 13 | -------------------------------------------------------------------------------- /generators/migrations/rename-enum-value/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { renameEnumValue } from '@wildebeest'; 4 | 5 | module.exports = renameEnumValue({ 6 | name: '{{ enumName }}', 7 | oldValue: '{{ oldValue }}', 8 | newValue: '{{ newValue }}', 9 | tableName: '{{ tableName }}', 10 | columnName: '{{ columnName }}', 11 | }); 12 | -------------------------------------------------------------------------------- /generators/migrations/addIndex/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { addIndex } from '@wildebeest'; 4 | 5 | module.exports = addIndex({ 6 | tableName: '{{ modelTableName }}', 7 | fields: [{{#listComma columns}}'{{ columnName }}'{{/listComma}}], 8 | {{#ifNotEquals method "BTREE"}} 9 | method: '{{ method }}', 10 | {{/ifNotEquals}} 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/pascalCase.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import camelCase from 'lodash/camelCase'; 3 | import upperFirst from 'lodash/upperFirst'; 4 | 5 | /** 6 | * Make a word pascal case 7 | * 8 | * @param word - The word to modify 9 | * @returns The word in pascal case 10 | */ 11 | export default function pascalCase(word: string): string { 12 | return upperFirst(camelCase(word)); 13 | } 14 | -------------------------------------------------------------------------------- /generators/migrations/removeIndex/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { removeIndex } from '@wildebeest'; 4 | 5 | module.exports = removeIndex({ 6 | tableName: '{{ modelTableName }}', 7 | fields: [{{#listComma columns}}'{{ columnName }}'{{/listComma}}], 8 | {{#ifNotEquals method "BTREE"}} 9 | method: '{{ method }}', 10 | {{/ifNotEquals}} 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/getBackoffTime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Exponential back-off algorithm https://gist.github.com/kitcambridge/11101250 3 | * 4 | * @param localAttempts - Number of attemps so far 5 | * @param delay - The delay interval 6 | * @returns The time to wait 7 | */ 8 | export default (localAttempts: number, delay: number): number => 9 | Math.floor(Math.random() * 2 ** localAttempts * delay); 10 | -------------------------------------------------------------------------------- /generateMixins/helpers/pascalCase.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import camelCase from 'lodash/camelCase'; 3 | import upperFirst from 'lodash/upperFirst'; 4 | 5 | /** 6 | * Make a word pascal case 7 | * 8 | * @param word - The word to modify 9 | * @returns The word in pascal case 10 | */ 11 | export default function pascalCase(word: string): string { 12 | return upperFirst(camelCase(word)); 13 | } 14 | -------------------------------------------------------------------------------- /generators/migrationTypes/dbMigration/actions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Add a new migration file 3 | * 4 | * @param data - The prompt answers 5 | * @param repo - The repository configuration 6 | * @returns The action 7 | */ 8 | export const ADD_MIGRATION_FILE = ({ templateName }) => ({ 9 | path: '{{ location }}/{{ migrationNumber }}-{{ name }}', 10 | templateName, 11 | type: 'addFile', 12 | }); 13 | -------------------------------------------------------------------------------- /src/migrationTypes/dropTable.ts: -------------------------------------------------------------------------------- 1 | // migrators 2 | import reverseMigrator from '@wildebeest/utils/reverseMigrator'; 3 | 4 | // local 5 | import createTable from './createTable'; 6 | 7 | /** 8 | * Remove a table from the db and all associated parts 9 | * 10 | * @param options - Options for dropping a table 11 | * @returns The drop table migrator 12 | */ 13 | export default reverseMigrator(createTable); 14 | -------------------------------------------------------------------------------- /src/migrationTypes/removeIndex.ts: -------------------------------------------------------------------------------- 1 | // migrators 2 | import reverseMigrator from '@wildebeest/utils/reverseMigrator'; 3 | 4 | // local 5 | import addIndex from './addIndex'; 6 | 7 | /** 8 | * Remove an existing index 9 | * 10 | * @param options - Options for making a unique constraint across multiple columns 11 | * @returns The remove index migrator 12 | */ 13 | export default reverseMigrator(addIndex); 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | 16 | [*.hbs] 17 | trim_trailing_whitespace = false 18 | insert_final_newline = false 19 | indent_style = space 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /generators/migrations/uuidNonNull/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make a uuid column non null 3 | */ 4 | 5 | export default { 6 | configure: ({ modelTableName, columnName, model }) => ({ 7 | name: `uuid-non-null-${modelTableName}-${columnName}`, 8 | comment: `Change column ${model}.${columnName} to be NON NULL.`, 9 | }), 10 | description: 'Make a uuid column non null', 11 | type: 'tableAssociationColumn', 12 | }; 13 | -------------------------------------------------------------------------------- /generators/migrations/renameS3Files/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { renameS3Files } from '@wildebeest'; 4 | 5 | module.exports = renameS3Files({ 6 | tableName: '{{ modelTableName }}', 7 | Bucket: {{{ bucket }}}, 8 | attributes: '{{{ attributes }}}', 9 | getOldKey: {{{ getOldKey }}}, 10 | getNewKey: {{{ getNewKey }}}, 11 | {{#if remove}} 12 | remove: true, 13 | {{/if}} 14 | }); 15 | -------------------------------------------------------------------------------- /src/migrationTypes/removeColumns.ts: -------------------------------------------------------------------------------- 1 | // migrators 2 | import reverseMigrator from '@wildebeest/utils/reverseMigrator'; 3 | 4 | // local 5 | import addColumns from './addColumns'; 6 | 7 | /** 8 | * Remove multiple columns on up, and add them back on down 9 | * 10 | * @param options - Options for adding new columns to a table 11 | * @returns The remove columns migrator 12 | */ 13 | export default reverseMigrator(addColumns); 14 | -------------------------------------------------------------------------------- /generators/generators/migration-type-generator/template.hbs: -------------------------------------------------------------------------------- 1 | {{> generatorImports }} 2 | 3 | /** 4 | * A higher order generator that creates a {{ sentenceCase container }} migrator. 5 | * 6 | * {{{ comment }}} 7 | */ 8 | export default { 9 | {{#if wantActions}} 10 | actions, 11 | {{/if}} 12 | {{#if generatorType}} 13 | parentType: '{{ generatorType }}', 14 | {{/if}} 15 | {{#if wantPrompts}} 16 | prompts, 17 | {{/if}} 18 | }; 19 | -------------------------------------------------------------------------------- /generators/generators/migration/template.hbs: -------------------------------------------------------------------------------- 1 | {{> generatorImports }} 2 | 3 | /** 4 | * {{{ comment }}} 5 | */ 6 | export default { 7 | configure: () => ({ 8 | name: `{{ paramCase container }}-${{ curly true }}{{{ nameExt }}}{{ curly false }}`, 9 | }), 10 | description: '{{{ comment }}}', 11 | {{#if wantPrompts}} 12 | prompts, 13 | {{/if}} 14 | {{#if generatorType}} 15 | type: '{{ generatorType }}', 16 | {{/if}} 17 | }; 18 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableMigration/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a migration that modifies a single table 3 | */ 4 | export default { 5 | parentType: 'dbMigration', 6 | prompts: { 7 | modelPath: (repo, { suggestOnly }) => ({ 8 | message: `What db model are you migrating?${ 9 | suggestOnly ? ' (suggestOnly)' : '' 10 | }`, 11 | suggestOnly, 12 | type: 'modelPath', 13 | }), 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/migrationTypes/makeColumnAllowNull.ts: -------------------------------------------------------------------------------- 1 | // migrators 2 | import reverseMigrator from '@wildebeest/utils/reverseMigrator'; 3 | 4 | // local 5 | import makeColumnNonNull from './makeColumnNonNull'; 6 | 7 | /** 8 | * Make a column allow null values 9 | * 10 | * @param options - The make column allow null options 11 | * @returns The make column allow null migrator 12 | */ 13 | export default reverseMigrator(makeColumnNonNull); 14 | -------------------------------------------------------------------------------- /generators/migrations/makeColumnNonNull/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make a column non null 3 | */ 4 | export default { 5 | configure: ({ modelTableName, columnName, model }) => ({ 6 | name: `make-column-non-null-${modelTableName}-${columnName}`, 7 | comment: `Make the column ${model}.${columnName} non null.`, 8 | }), 9 | description: 'Make a column non null', 10 | // columnSuggestOnly: false, 11 | type: 'tableColumnMigration', 12 | }; 13 | -------------------------------------------------------------------------------- /src/migrationTypes/skip.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { MigrationDefinition, ModelMap } from '@wildebeest/types'; 3 | 4 | /** 5 | * Skip the migrator because it was later removed 6 | * 7 | * @returns The skip migrator 8 | */ 9 | export default function skip(): MigrationDefinition< 10 | TModels 11 | > { 12 | return { 13 | up: () => Promise.resolve(null), 14 | down: () => Promise.resolve(null), 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnMigration/typeDefs.ts: -------------------------------------------------------------------------------- 1 | // ///// // 2 | // Types // 3 | // ///// // 4 | 5 | /** 6 | * Information about a table attribute 7 | * 8 | * @typedef {Object} AttributeDefinition 9 | * @property {string} type - Type (attribute or association) 10 | * @property {string} relativePath - The relative path to the definition of the column 11 | * @property {string} columnName - The name of the associated column 12 | */ 13 | -------------------------------------------------------------------------------- /generators/migrations/bulkInsert/insert.hbs: -------------------------------------------------------------------------------- 1 | // TODO 2 | const [TODO] = await queryInterface.sequelize.query(`SELECT * FROM ${TODO}`); 3 | 4 | // Create the {{ model }} inputs 5 | const inputs = TODO.map(() => ({ 6 | id: uuidv4(), 7 | updatedAt: new Date(), 8 | createdAt: new Date(), 9 | TODO, 10 | })); 11 | 12 | // Bulk insert {{ pluralCase model }} 13 | if (inputs.length > 0) await queryInterface.bulkInsert('{{ pluralCase model }}', inputs); -------------------------------------------------------------------------------- /src/utils/createAttributes.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { Attributes } from '@wildebeest/types'; 3 | 4 | /** 5 | * Identity function that will type-enforce attribute definitions 6 | * 7 | * @param attributes - The db model attributes 8 | * @returns The type-enforced attributes 9 | */ 10 | export default function createAttributes( 11 | attributes: TAttributes, 12 | ): TAttributes { 13 | return attributes; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/escapeJson.ts: -------------------------------------------------------------------------------- 1 | import type { ObjByString } from '@wildebeest/types'; 2 | 3 | /** 4 | * Escape JSON that may have single quotes in it before inserting into SQL statement 5 | * 6 | * @param obj - The object to check 7 | */ 8 | export default function escapeJson( 9 | obj: T[] | T, 10 | ): string { 11 | return `cast('${JSON.stringify(obj) 12 | .split("'") 13 | .join("' || CHR(39) || '")}' as json)`; 14 | } 15 | -------------------------------------------------------------------------------- /generators/migrations/makeColumnUnique/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make a table column unique 3 | */ 4 | export default { 5 | configure: ({ modelTableName, columnName, model }) => ({ 6 | name: `make-column-unique-${modelTableName}-${columnName}`, 7 | comment: `Add a constraint to make ${model}.${columnName} unique.`, 8 | }), 9 | description: 'Make a table column unique', 10 | columnSuggestOnly: false, 11 | type: 'tableColumnMigration', 12 | }; 13 | -------------------------------------------------------------------------------- /src/migrationTypes/makeColumnNotUnique.ts: -------------------------------------------------------------------------------- 1 | // migrators 2 | import reverseMigrator from '@wildebeest/utils/reverseMigrator'; 3 | 4 | // local 5 | import makeColumnUnique from './makeColumnUnique'; 6 | 7 | /** 8 | * Remove a unique constrain on a column 9 | * 10 | * @param options - Options for making a column unique 11 | * @returns The remove unique constraint index migrator migrator 12 | */ 13 | export default reverseMigrator(makeColumnUnique); 14 | -------------------------------------------------------------------------------- /src/migrationTypes/removeEnumValues.ts: -------------------------------------------------------------------------------- 1 | // migrators 2 | import reverseMigrator from '@wildebeest/utils/reverseMigrator'; 3 | 4 | // local 5 | import addEnumValues from './addEnumValues'; 6 | 7 | /** 8 | * Remove values from an enum on up, and add them back on down 9 | * 10 | * @param options - Options for removing attributes from the enum 11 | * @returns The remove enum values migrator 12 | */ 13 | export default reverseMigrator(addEnumValues); 14 | -------------------------------------------------------------------------------- /generators/migrations/bulkDelete/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // external 3 | import uuidv4 from 'uuid/v4'; 4 | 5 | // wildebeest 6 | import { custom } from '@wildebeest'; 7 | 8 | throw new Error('FIX TODO IN MIGRATION {{ migrationNumber }}'); 9 | module.exports = custom({ 10 | up: async ({ queryT }) => 11 | queryT.insert('{{ modelTableName }}', []), // TODO 12 | down: async ({ queryT }) => queryT.delete('{{ modelTableName }}', { TODO }), 13 | }); 14 | -------------------------------------------------------------------------------- /generators/migrations/bulkInsert/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // external 3 | import uuidv4 from 'uuid/v4'; 4 | 5 | // wildebeest 6 | import { custom } from '@wildebeest'; 7 | 8 | throw new Error('FIX TODO IN MIGRATION {{ migrationNumber }}'); 9 | module.exports = custom({ 10 | up: async ({ queryT }) => 11 | queryT.insert('{{ modelTableName }}', []), // TODO 12 | down: async ({ queryT }) => queryT.delete('{{ modelTableName }}', { TODO }), 13 | }); 14 | -------------------------------------------------------------------------------- /generators/migrations/changeColumn/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { changeColumn } from '@wildebeest'; 4 | 5 | module.exports = changeColumn({ 6 | {{> tableColumnMigration }} 7 | 8 | getOldColumn: ({ DataTypes }) => ({ 9 | {{> tableColumnsMigrationDefinition definition=oldDefinition }} 10 | }), 11 | getNewColumn: ({ DataTypes }) => ({ 12 | {{> tableColumnsMigrationDefinition definition=newDefinition }} 13 | }), 14 | }); 15 | -------------------------------------------------------------------------------- /generators/migrations/makeColumnAllowNull/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make a column allow null values 3 | */ 4 | export default { 5 | configure: ({ modelTableName, columnName, model }) => ({ 6 | name: `make-column-allow-null-${modelTableName}-${columnName}`, 7 | comment: `Make the column ${model}.${columnName} allow null values.`, 8 | }), 9 | description: 'Make a column allow null values', 10 | // columnSuggestOnly: false, 11 | type: 'tableColumnMigration', 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/isEnum.ts: -------------------------------------------------------------------------------- 1 | // db 2 | import * as sequelize from 'sequelize'; 3 | 4 | /** 5 | * Check if an attribute is an enum 6 | * 7 | * @param column - The column definition to check 8 | * @returns True if the column is an enum definition 9 | */ 10 | export default function isEnum( 11 | column: sequelize.ModelAttributeColumnOptions, 12 | ): boolean { 13 | return typeof column.type === 'string' 14 | ? column.type === 'ENUM' 15 | : column.type.key === 'ENUM'; 16 | } 17 | -------------------------------------------------------------------------------- /generators/generators/migration-type/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a higher order migration definition 3 | * 4 | * TODO wildebeest 5 | */ 6 | export default { 7 | defaultName: 'createTable', 8 | description: 'Create a higher order migration definition', 9 | location: 'migrations/migrationTypes', 10 | mode: 'backend', 11 | nameTransform: 'camelCase', 12 | objectParams: 'options', 13 | skipActions: true, 14 | skipReturnPrompts: true, 15 | type: 'functionFile', 16 | }; 17 | -------------------------------------------------------------------------------- /generators/migrations/addCascadeWithParent/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migrate a column so that it cascades with its parent 3 | */ 4 | export default { 5 | configure: ({ modelTableName, columnName, model }) => ({ 6 | name: `add-cascade-with-parent-${modelTableName}-${columnName}`, 7 | comment: `Add cascade with parent constraint to ${model}.${columnName}.`, 8 | }), 9 | description: 'Migrate a column so that it cascades with its parent', 10 | type: 'tableAssociationColumn', 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/createHooks.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { WildebeestModel } from '@wildebeest/classes'; 3 | import { HookOptions, ModelMap } from '@wildebeest/types'; 4 | 5 | /** 6 | * Create database model hooks for a db model 7 | * 8 | * @param hooks - The hooks 9 | * @returns The type-enforced hooks 10 | */ 11 | export default function createHooks>( 12 | hooks: Partial>, 13 | ): Partial> { 14 | return hooks; 15 | } 16 | -------------------------------------------------------------------------------- /generators/migrations/removeEnumValues/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove values from an existing enum 3 | */ 4 | export default { 5 | configure: ({ enumName, attributes }) => ({ 6 | name: `remove-enum-values-from-${enumName}`, 7 | comment: `Remove the attributes: ${attributes 8 | .map((attr) => attr.name) 9 | .join(', ')} from ${enumName}.`, 10 | }), 11 | description: 'Remove values from an existing enum', 12 | attributesSuggestOnly: true, 13 | type: 'changeEnumAttributes', 14 | }; 15 | -------------------------------------------------------------------------------- /generators/migrationTypes/tablesMigration/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A higher order generator that creates a Tables migration migrator. 3 | * 4 | * Migrate multiple tables at the same time 5 | */ 6 | export default { 7 | parentType: 'dbMigration', 8 | prompts: { 9 | tables: { 10 | childName: 'table', 11 | message: 'Choose another table?', 12 | prompts: { 13 | modelPath: { 14 | type: 'modelPath', 15 | }, 16 | }, 17 | type: 'recursive', 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /generators/migrations/bulkDelete/delete.hbs: -------------------------------------------------------------------------------- 1 | // Get the {{ pluralCase model }} 2 | const [{{ pluralCase model }}] = await queryInterface.sequelize.query('SELECT * FROM "{{ pluralCase model }}"'); 3 | 4 | // Update TODO 5 | await Promise.all({{ pluralCase model }}.map(() => queryInterface.sequelize.query(` 6 | UPDATE "${TODO}" 7 | SET "${TODO}"='${TODO}' 8 | WHERE "${TODO}"='${TODO}'; 9 | `))); 10 | 11 | // Bulk delete {{ pluralCase model }} 12 | await queryInterface.bulkDelete('{{ pluralCase model }}', { 13 | TODO 14 | }); -------------------------------------------------------------------------------- /generators/generators/migration-type-generator/index.ts: -------------------------------------------------------------------------------- 1 | // generators 2 | import migration from '../migration/prompts'; 3 | 4 | /** 5 | * Create a new generator to accompany a migration type 6 | * 7 | * TODO wildebeest 8 | */ 9 | export default { 10 | description: 'Create a new generator to accompany a migration type', 11 | displayGeneratorName: 'migration-type', 12 | location: 'migrationTypes', 13 | prompts: { 14 | generatorType: migration.prompts.generatorType, 15 | }, 16 | type: 'generatorContainer', 17 | }; 18 | -------------------------------------------------------------------------------- /generators/migrations/addEnumValues/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Add values to an existing enum 3 | */ 4 | export default { 5 | configure: ({ attributes, enumName }) => ({ 6 | name: `add-enum-values-to-${enumName}`, 7 | comment: `Add the attributes: ${attributes 8 | .map((attr) => attr.name) 9 | .join(', ')} to ${enumName}`, 10 | }), 11 | description: 'Add values to an existing enum', 12 | attributesSuggestOnly: true, // TODO remove when enums can recursively detect 13 | type: 'changeEnumAttributes', 14 | }; 15 | -------------------------------------------------------------------------------- /generators/migrations/cascadeWithParent/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Update a column to have an on delete cascade when it's parent is cascaded 3 | */ 4 | export default { 5 | configure: ({ modelTableName, columnName, model }) => ({ 6 | name: `cascade-with-parent-${modelTableName}-${columnName}`, 7 | comment: `Update constraint for the column ${model}.${columnName} to cascade with parent.`, 8 | }), 9 | description: 10 | "Update a column to have an on delete cascade when it's parent is cascaded", 11 | type: 'tableAssociationColumn', 12 | }; 13 | -------------------------------------------------------------------------------- /src/migrationTypes/removeUniqueConstraintIndex.ts: -------------------------------------------------------------------------------- 1 | // migrators 2 | import reverseMigrator from '@wildebeest/utils/reverseMigrator'; 3 | 4 | // local 5 | import addUniqueConstraintIndex from './addUniqueConstraintIndex'; 6 | 7 | /** 8 | * Remove a unique constrain across multiple columns on up, and add it back on down 9 | * 10 | * @param options - Options for making a unique constraint across multiple columns 11 | * @returns The remove unique constraint index migrator migrator 12 | */ 13 | export default reverseMigrator(addUniqueConstraintIndex); 14 | -------------------------------------------------------------------------------- /generators/migrations/custom/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A custom migration 3 | */ 4 | export default { 5 | configure: ({ modelTableName, name }) => ({ 6 | name: `${modelTableName}-${name}`, 7 | }), 8 | description: 'A custom migration', 9 | prompts: { 10 | name: { 11 | message: 'What is the migration name?', 12 | nameTransform: 'paramCase', 13 | type: 'name', 14 | }, 15 | comment: { 16 | message: 'What is the migration comment?', 17 | type: 'comment', 18 | }, 19 | }, 20 | type: 'tableMigration', 21 | }; 22 | -------------------------------------------------------------------------------- /generators/migrations/removeNonNullColumn/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove a column that is not allowed to be null, and when down is run, allow the column to be null 3 | */ 4 | export default { 5 | configure: ({ modelTableName, columnName, model }) => ({ 6 | name: `remove-non-null-column-${modelTableName}-${columnName}`, 7 | comment: `Remove non null column ${model}.${columnName}.`, 8 | }), 9 | description: 10 | 'Remove a column that is not allowed to be null, and when down is run, allow the column to be null', 11 | type: 'tableColumnMigration', 12 | }; 13 | -------------------------------------------------------------------------------- /generators/migrations/addColumns/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Add columns to a table 3 | */ 4 | export default { 5 | configure: ({ modelTableName, columns, model }) => ({ 6 | name: `add-columns-${modelTableName}-${columns 7 | .map(({ columnName }) => columnName) 8 | .join('-')}`, 9 | comment: `Add columns (${columns 10 | .map(({ columnName }) => columnName) 11 | .join(', ')}) to ${model}.`, // eslint-disable-line max-len 12 | }), 13 | description: 'Add columns to a table', 14 | withGetRowDefaults: true, 15 | type: 'tableColumnsMigration', 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/clearSchema.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import sequelize from 'sequelize'; 3 | 4 | /** 5 | * Re create a schema 6 | * 7 | * @param queryInterface - The sequelize queryInterface 8 | * @param schema - The name of the schema 9 | * @returns The refresh schema promise 10 | */ 11 | export default async function clearSchema( 12 | queryInterface: sequelize.QueryInterface, 13 | schema = 'public', 14 | ): Promise { 15 | // Drop the schema 16 | await queryInterface.dropSchema(schema); 17 | 18 | // Re-create it 19 | await queryInterface.createSchema(schema); 20 | } 21 | -------------------------------------------------------------------------------- /generators/migrations/renameTable/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Rename a table 3 | */ 4 | export default { 5 | configure: ({ newName, oldName }) => ({ 6 | name: `rename-table-to-${newName}`, 7 | comment: `Rename the table ${oldName} to ${newName}.`, 8 | }), 9 | description: 'Rename a table', 10 | prompts: { 11 | oldName: { 12 | message: 'What was the old name of the table?', 13 | type: 'name', 14 | }, 15 | newName: { 16 | message: 'What is the new name of the table?', 17 | type: 'name', 18 | }, 19 | }, 20 | type: 'dbMigration', 21 | }; 22 | -------------------------------------------------------------------------------- /generators/migrations/changeEnumColumn/template.hbs: -------------------------------------------------------------------------------- 1 | {{> dbMigrationHeader }} 2 | // migrationTypes 3 | import { changeEnumColumn } from '@wildebeest'; 4 | 5 | module.exports = changeEnumColumn({ 6 | {{> tableColumnMigration }} 7 | 8 | {{#if oldDefault}} 9 | oldDefault: '{{ oldDefault }}', 10 | {{/if}} 11 | {{#if newDefault}} 12 | newDefault: '{{ newDefault }}', 13 | {{/if}} 14 | oldEnum: { 15 | {{> changeEnumColumnAttributes attributes=oldAttributes }} 16 | 17 | }, 18 | newEnum: { 19 | {{> changeEnumColumnAttributes attributes=newAttributes }} 20 | 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/utils/freshIndexes.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { ModelOptions } from 'sequelize'; 3 | 4 | /** 5 | * Copy over indexes to new object. This is to ensure that any inherited options are not just a reference to another tables options. 6 | * 7 | * @param options - The db model options to copy 8 | * @returns The copied options 9 | */ 10 | export default function freshIndexes(options?: ModelOptions): ModelOptions { 11 | if (!options || !options.indexes) { 12 | return {}; 13 | } 14 | return { 15 | ...options, 16 | indexes: options.indexes.map((index) => ({ ...index })), 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /generators/generators/migration-type/template.hbs: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * {{{ comment }}} 4 | * 5 | {{#if params}} 6 | {{> functionParamsComment }} 7 | 8 | {{/if}} 9 | * @returns The {{ lowerCase (sentenceCase name) }} migrator 10 | */ 11 | {{{ defaultExportType }}} function custom( 12 | {{> functionParams }}, 13 | ): MigrationDefinition { 14 | const { up, down } = options; 15 | return { 16 | up: async (db, withTransaction) => 17 | withTransaction((transactionOptions) => TODO), 18 | down: async (db, withTransaction) => 19 | withTransaction((transactionOptions) => TODO), 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /generators/migrations/removeIndex/index.ts: -------------------------------------------------------------------------------- 1 | import { method } from '../addIndex/prompts'; 2 | 3 | /** 4 | * Remove an existing index 5 | */ 6 | export default { 7 | configure: ({ columns, model }) => ({ 8 | name: `remove-index-${columns 9 | .map(({ columnName }) => columnName) 10 | .join('-')}`, 11 | comment: `Remove index on ${model} fields: ${columns 12 | .map(({ columnName }) => columnName) 13 | .join(', ')}.`, // eslint-disable-line max-len 14 | }), 15 | description: 'Remove an existing index', 16 | prompts: { method }, 17 | type: 'tableColumnsMigration', 18 | }; 19 | -------------------------------------------------------------------------------- /generators/migrations/bulkInsert/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Run a bulk insert 3 | */ 4 | export default { 5 | configure: ({ modelTableName, modelContainer, nameExt }) => ({ 6 | name: `bulk-insert-${modelTableName}${nameExt ? `-${nameExt}` : ''}`, 7 | comment: `Bulk insert into table [${modelTableName}]{@link module:${modelContainer}}`, 8 | }), 9 | description: 'Run a bulk insert', 10 | prompts: { 11 | nameExt: { 12 | message: 13 | 'Name the migration (will be appended after "bulk-insert-{{ modelTableName }}-"', 14 | type: 'name', 15 | }, 16 | }, 17 | type: 'tableMigration', 18 | }; 19 | -------------------------------------------------------------------------------- /generators/migrations/bulkDelete/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Bulk delete table rows 3 | */ 4 | export default { 5 | configure: ({ modelTableName, modelContainer, nameExt }) => ({ 6 | name: `bulk-delete-${modelTableName}${nameExt ? `-${nameExt}` : ''}`, 7 | comment: `Bulk delete from table [${modelTableName}]{@link module:${modelContainer}}`, 8 | }), 9 | description: 'Bulk delete table rows', 10 | prompts: { 11 | nameExt: { 12 | message: 13 | 'Name the migration (will be appended after "bulk-delete-{{ modelTableName }}-"', 14 | type: 'name', 15 | }, 16 | }, 17 | type: 'tableMigration', 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/getNextNumber.ts: -------------------------------------------------------------------------------- 1 | // local 2 | import { parseNumber } from './getNumberedList'; 3 | 4 | /** 5 | * Get the next number from a list of files or folders 6 | * 7 | * Default is to check for folders 8 | * 9 | * ```typescript 10 | * // Returns 0003 11 | * getNextNumber(['0001-my-first.ts', '0002-my-second.ts']); 12 | * ``` 13 | * 14 | * @param items - The list of numbered items 15 | * @returns The next number in line as a string 16 | */ 17 | export default function getNextNumber(items: string[]): string { 18 | return (Math.max(0, ...items.map(parseNumber)) + 1) 19 | .toString() 20 | .padStart(4, '0'); 21 | } 22 | -------------------------------------------------------------------------------- /src/models/migrationLock/definition.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { DataTypes } from 'sequelize'; 3 | 4 | // global 5 | import createAttributes from '@wildebeest/utils/createAttributes'; 6 | 7 | /** 8 | * Attribute definitions for the model 9 | */ 10 | const attributes = createAttributes({ 11 | /** Indicates that the migrator should be locked because another instance is running the migrations. */ 12 | isLocked: { 13 | allowNull: false, 14 | defaultValue: false, 15 | type: DataTypes.BOOLEAN, 16 | unique: 'isLocked must be unique', 17 | }, 18 | }); 19 | 20 | // Model definition 21 | export default { 22 | attributes, 23 | tableName: 'migrationLocks', 24 | }; 25 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnMigration/index.ts: -------------------------------------------------------------------------------- 1 | // local 2 | import { listAllAttributes } from './lib'; 3 | 4 | /** 5 | * A higher order generator that creates a Table column migration migrator. 6 | * 7 | * Migrate a table column 8 | */ 9 | export default { 10 | parentType: 'tableMigration', 11 | prompts: { 12 | columnName: (repo, { columnSuggestOnly = true }) => ({ 13 | message: `What column should be modified? ${ 14 | columnSuggestOnly ? '(suggestOnly)' : '' 15 | }`, 16 | source: ({ modelPath }) => listAllAttributes(repo, modelPath), 17 | suggestOnly: columnSuggestOnly, 18 | type: 'autocomplete', 19 | }), 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /generators/migrations/removeColumns/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove multiple columns on up, and add them back on down 3 | */ 4 | export default { 5 | configure: ({ modelTableName, columns, model }) => ({ 6 | name: `remove-columns-${modelTableName}-${columns 7 | .map(({ columnName }) => columnName) 8 | .join('-')}`, 9 | comment: `Remove columns (${columns 10 | .map(({ columnName }) => columnName) 11 | .join(', ')}) to ${model}.`, // eslint-disable-line max-len 12 | }), 13 | description: 'Remove multiple columns on up, and add them back on down', 14 | columnSuggestOnly: true, 15 | withGetRowDefaults: true, 16 | type: 'tableColumnsMigration', 17 | }; 18 | -------------------------------------------------------------------------------- /generators/migrations/removeUniqueConstraintIndex/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Remove a unique constrain across multiple columns on up, and add it back on down 3 | */ 4 | export default { 5 | configure: ({ columns, model }) => ({ 6 | name: `remove-unique-constraint-index-${columns 7 | .map(({ columnName }) => columnName) 8 | .join('-')}`, 9 | comment: `Remove unique index on ${model} fields: ${columns 10 | .map(({ columnName }) => columnName) 11 | .join(', ')}.`, // eslint-disable-line max-len 12 | }), 13 | description: 14 | 'Remove a unique constrain across multiple columns on up, and add it back on down', 15 | suggestOnly: true, 16 | type: 'tableColumnsMigration', 17 | }; 18 | -------------------------------------------------------------------------------- /generators/migrations/addUniqueConstraintIndex/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Add a unique constrain across multiple columns on up, and remove the constraint on down 3 | */ 4 | export default { 5 | configure: ({ columns, model }) => ({ 6 | name: `add-unique-constraint-index-${columns 7 | .map(({ columnName }) => columnName) 8 | .join('-')}`, 9 | comment: `Add unique index on ${model} fields: ${columns 10 | .map(({ columnName }) => columnName) 11 | .join(', ')}.`, // eslint-disable-line max-len 12 | }), 13 | description: 14 | 'Add a unique constrain across multiple columns on up, and remove the constraint on down', 15 | skipColumnPrompts: true, 16 | type: 'tableColumnsMigration', 17 | }; 18 | -------------------------------------------------------------------------------- /generators/migrations/changeColumn/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Change a column definition 3 | */ 4 | export default { 5 | configure: ({ columnName, model }) => ({ 6 | name: `change-column-${columnName}`, 7 | comment: `Change column "${columnName}" on table ${model}`, 8 | }), 9 | description: 'Change a column definition', 10 | prompts: { 11 | oldDefinition: { 12 | message: 'What is the old column definition?', 13 | extension: 'hbs', 14 | type: 'editor', 15 | }, 16 | newDefinition: { 17 | message: 'What is the new definition?', 18 | extension: 'hbs', 19 | type: 'editor', 20 | }, 21 | }, 22 | type: 'tableColumnMigration', // TODO 'tablesColumnMigration', 23 | }; 24 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableColumnsMigration/regexs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Detect if an attribute definition has a validator 3 | */ 4 | export const ATTRIBUTE_VALIDATOR_REGEX = /validate: ([A-Z_].+?),/; 5 | 6 | /** 7 | * Create an attribute regex 8 | * 9 | * @param name - The name of the attribute 10 | * @param exportType - The manner in which the attribute is exported (i.e. export const ) 11 | * @returns The regex for matching comment and definition from an attribute 12 | */ 13 | export const ATTRIBUTE_VALUE_REGEX = ( 14 | name: string, 15 | exportType: string, 16 | ): RegExp => 17 | new RegExp( 18 | `\\/\\*\\*\\n \\* (.+?)\\n \\*[\\s\\S]+${exportType}${name}: ModelAttributeColumnOptions = {([\\s\\S]+?)};`, 19 | ); 20 | -------------------------------------------------------------------------------- /generators/migrationTypes/dbMigration/index.ts: -------------------------------------------------------------------------------- 1 | // commons 2 | import getNextNumber from '@wildebeest/utils/getNextNumber'; 3 | 4 | // local 5 | import actions from './actions'; 6 | 7 | /** 8 | * A higher order generator that creates a Db migration 9 | * 10 | * A database migration 11 | */ 12 | export default { 13 | actions, 14 | configure: ({ location, generatorName }, repo) => ({ 15 | migrationNumber: getNextNumber(repo.getEntryFolder(location)), 16 | templateName: repo.hasTemplate(generatorName) 17 | ? generatorName 18 | : 'db-migration', 19 | }), 20 | location: 'migrations/migrations', 21 | locationIsFolder: true, 22 | mode: 'backend', 23 | skipActions: true, 24 | requiredProps: [], 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/logSection.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import Logger from '@wildebeest/Logger'; 3 | 4 | /** 5 | * Run some code wrapped in a logged header 6 | * 7 | * @param logger - The logger to use 8 | * @param cb - The code to run 9 | * @param header - The header of the log section 10 | * @returns The section execution promise wrapped in logging 11 | */ 12 | export default async function logSection( 13 | logger: Logger, 14 | cb: () => any, // eslint-disable-line @typescript-eslint/no-explicit-any 15 | header = 'Migrations', 16 | ): Promise { 17 | // Log start 18 | logger.divide(); 19 | logger.info(`${header}:\n`); 20 | 21 | // Run the cb 22 | await Promise.resolve(cb()); 23 | 24 | // Log finish 25 | logger.divide(); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/tableExists.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { QueryTypes, Sequelize, Transaction } from 'sequelize'; 3 | 4 | /** 5 | * Check if the model table exists 6 | * 7 | * @param db - The db to check against 8 | * @param tableName - The name of the table to check 9 | * @param transaction - The current transaction 10 | * @returns True if the table exists 11 | */ 12 | export default async function tableExists( 13 | db: Sequelize, 14 | tableName: string, 15 | transaction?: Transaction, 16 | ): Promise { 17 | return db 18 | .query( 19 | `SELECT * FROM pg_catalog.pg_tables WHERE tablename='${tableName}' `, 20 | { type: QueryTypes.SELECT, transaction }, 21 | ) 22 | .then((tables) => tables.length === 1); 23 | } 24 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableAssociationColumn/index.ts: -------------------------------------------------------------------------------- 1 | // local 2 | import { getAssociations } from './lib'; 3 | 4 | /** 5 | * A higher order generator that creates a Table association column migrator. 6 | * 7 | * A migrator for table column that is an association 8 | */ 9 | export default { 10 | parentType: 'tableMigration', 11 | prompts: { 12 | columnName: (repo) => ({ 13 | message: 'What column should be modified? (suggestOnly)', 14 | source: ({ modelPath }) => 15 | getAssociations(modelPath, repo) 16 | .filter(({ associationType }) => associationType === 'belongsTo') 17 | .map(({ name }) => `${name}Id`), 18 | suggestOnly: true, 19 | type: 'autocomplete', 20 | }), 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/reverseMigrator.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { MigrationDefinition, ModelMap } from '@wildebeest/types'; 3 | 4 | /** 5 | * Reverse a higher order generator so down returns up and up returns down 6 | * 7 | * @param createMigrator - The higher order migrator to reverse 8 | * @returns The higher order migrator reversed 9 | */ 10 | export default function reverseMigrator( 11 | createMigrator: (options: TOptions) => MigrationDefinition, 12 | ): (options: TOptions) => MigrationDefinition { 13 | return (options: TOptions): MigrationDefinition => { 14 | const { up, down } = createMigrator(options); 15 | return { 16 | down: up, 17 | up: down, 18 | }; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /generators/migrations/addIndex/index.ts: -------------------------------------------------------------------------------- 1 | // commons 2 | import { IndexMethod } from '@wildebeest/types'; 3 | 4 | /** 5 | * Add a new index to a table 6 | */ 7 | export default { 8 | configure: ({ columns, model }) => ({ 9 | name: `add-index-${columns.map(({ columnName }) => columnName).join('-')}`, 10 | comment: `Add index on ${model} fields: ${columns 11 | .map(({ columnName }) => columnName) 12 | .join(', ')}.`, 13 | }), 14 | description: 'Add a new index to a table', 15 | prompts: { 16 | method: { 17 | default: IndexMethod.BTree, 18 | message: 'What method of index?', 19 | source: () => Object.values(IndexMethod), 20 | type: 'autocomplete', 21 | }, 22 | }, 23 | type: 'tableColumnsMigration', 24 | }; 25 | -------------------------------------------------------------------------------- /generators/migrationTypes/tableAssociationColumn/lib.ts: -------------------------------------------------------------------------------- 1 | // commons 2 | import listAssociations from '@commons/utils/listAssociations'; 3 | 4 | // utils 5 | import modelPathToAssociationPath from '@generators/utils/modelPathToAssociationPath'; 6 | 7 | /** 8 | * Get the associations from a modelPath 9 | * 10 | * @param modelPath - The path to the model definition index file 11 | * @param repo - The repository configuration 12 | * @returns The associations 13 | */ 14 | export const getAssociations = function getAssociations( 15 | modelPath, 16 | repo, 17 | ): AssociationConfig[] { 18 | const loc = modelPathToAssociationPath(modelPath); 19 | if (!repo.hasEntryFile(loc)) { 20 | return []; 21 | } 22 | return listAssociations(repo.getEntryFile(loc)); 23 | }; 24 | -------------------------------------------------------------------------------- /generators/migrations/renameConstraint/index.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import kebabCase from 'lodash/kebabCase'; 3 | 4 | /** 5 | * Rename a table column constraint 6 | */ 7 | export default { 8 | configure: ({ model, columnName, newName, oldName }) => ({ 9 | name: `rename-constraint-${kebabCase(newName)}`, 10 | comment: `Rename the constraint ${oldName} for ${model}.${columnName} to ${newName}.`, 11 | }), 12 | description: 'Rename a table column constraint', 13 | prompts: { 14 | oldName: { 15 | message: 'What was the old name of the constraint?', 16 | type: 'name', 17 | }, 18 | newName: { 19 | message: 'What is the new name of the constraint?', 20 | type: 'name', 21 | }, 22 | }, 23 | type: 'tableColumnMigration', 24 | }; 25 | -------------------------------------------------------------------------------- /generators/migrationTypes/tablesColumnMigration/index.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import intersection from 'lodash/intersection'; 3 | 4 | // migrationTypes 5 | import { listAllAttributes } from '../tableColumnMigration/lib'; 6 | 7 | /** 8 | * A higher order generator that creates a Tables column migration migrator. 9 | * 10 | * Migrate the same column on multiple tables 11 | */ 12 | export default { 13 | parentType: 'tablesMigration', 14 | prompts: { 15 | columnName: (repo) => ({ 16 | message: 'What column should be modified? (suggestOnly)', 17 | source: ({ tables }) => 18 | intersection( 19 | tables.map(({ modelPath }) => listAllAttributes(repo, modelPath)), 20 | ), 21 | suggestOnly: true, 22 | type: 'autocomplete', 23 | }), 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/invert.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Invert an object so that the values look up the keys. 3 | * If the object has an array as the value, each item in the array will be inverted. 4 | * 5 | * @param obj - The object to invert 6 | * @returns The inverted object 7 | */ 8 | export default function invert( 9 | obj: { [key in string]: string | string[] }, 10 | ): { [key in string]: string } { 11 | const result: { [key in string]: string } = {}; 12 | 13 | // Invert 14 | Object.keys(obj).forEach((key: string) => { 15 | const instance = obj[key]; 16 | if (typeof instance === 'string') { 17 | result[instance] = key; 18 | } else { 19 | (obj[key] as string[]).forEach((listKey: string) => { 20 | result[listKey] = key; 21 | }); 22 | } 23 | }); 24 | return result; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/getKeys.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { StringKeys } from '@wildebeest/types'; 3 | 4 | /** 5 | * Object.keys for string keys only 6 | * 7 | * @param o - The object to get the keys from 8 | * @returns The string keys of the object preserving type 9 | */ 10 | export function getStringKeys( 11 | o: T, 12 | ): StringKeys[] { 13 | return Object.keys(o).filter((k) => typeof k === 'string') as StringKeys[]; 14 | } 15 | 16 | /** 17 | * Object.keys that actually preserves keys as types. 18 | * 19 | * @param o - The object to get the keys from 20 | * @returns The keys of the object preserving type 21 | */ 22 | export default function getKeys( 23 | o: T, 24 | ): (keyof T)[] { 25 | return Object.keys(o) as (keyof T)[]; 26 | } 27 | -------------------------------------------------------------------------------- /generateMixins/helpers/getKeys.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { StringKeys } from '../types'; 3 | 4 | /** 5 | * Object.keys for string keys only 6 | * 7 | * @param o - The object to get the keys from 8 | * @returns The string keys of the object preserving type 9 | */ 10 | export function getStringKeys( 11 | o: T, 12 | ): StringKeys[] { 13 | return Object.keys(o).filter((k) => typeof k === 'string') as StringKeys[]; 14 | } 15 | 16 | /** 17 | * Object.keys that actually preserves keys as types. 18 | * 19 | * @param o - The object to get the keys from 20 | * @returns The keys of the object preserving type 21 | */ 22 | export default function getKeys( 23 | o: T, 24 | ): (keyof T)[] { 25 | return Object.keys(o) as (keyof T)[]; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/getForeignKeyName.ts: -------------------------------------------------------------------------------- 1 | // db 2 | import { Association } from '@wildebeest/types'; 3 | 4 | /** 5 | * Get the name of the foreign key if it is provided 6 | * 7 | * @param association - The association to get the foreign key name from 8 | * @param defaultValue - When none is provided, fall back to this 9 | * @returns The name of the foreign key or null if not provided 10 | */ 11 | export default function getForeignKeyName( 12 | association: Association, 13 | defaultValue = 'id', 14 | ): string { 15 | if (typeof association === 'string' || !association.foreignKey) { 16 | return defaultValue; 17 | } 18 | if (typeof association.foreignKey === 'string') { 19 | return association.foreignKey; 20 | } 21 | return association.foreignKey.name || defaultValue; 22 | } 23 | -------------------------------------------------------------------------------- /generators/migrations/renameIndex/index.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { NOT_EMPTY_REGEX } from '@generators/regexes'; 3 | 4 | /** 5 | * Rename an index 6 | */ 7 | export default { 8 | configure: ({ oldName, newName }) => ({ 9 | name: `rename-index-to-${newName}`, 10 | comment: `Rename the index ${oldName} to ${newName}.`, 11 | }), 12 | description: 'Rename an index', 13 | prompts: { 14 | oldName: { 15 | message: 'What is the old index name?', 16 | type: 'input', 17 | validate: (value) => 18 | NOT_EMPTY_REGEX.test(value) ? true : 'oldName is required', 19 | }, 20 | newName: { 21 | message: 'What is the new name?', 22 | type: 'input', 23 | validate: (value) => 24 | NOT_EMPTY_REGEX.test(value) ? true : 'newName is required', 25 | }, 26 | }, 27 | type: 'dbMigration', 28 | }; 29 | -------------------------------------------------------------------------------- /generators/migrationTypes/index.ts: -------------------------------------------------------------------------------- 1 | // local 2 | import alterEnum from './alterEnum'; 3 | import changeEnumAttributes from './changeEnumAttributes'; 4 | import dbMigration from './dbMigration'; 5 | import tableAssociationColumn from './tableAssociationColumn'; 6 | import tableColumnMigration from './tableColumnMigration'; 7 | import tableColumnsMigration from './tableColumnsMigration'; 8 | import tableMigration from './tableMigration'; 9 | import tablesColumnMigration from './tablesColumnMigration'; 10 | import tablesMigration from './tablesMigration'; 11 | 12 | /** 13 | * Types of migration generators 14 | */ 15 | export default { 16 | alterEnum, 17 | changeEnumAttributes, 18 | dbMigration, 19 | tableAssociationColumn, 20 | tableColumnMigration, 21 | tableColumnsMigration, 22 | tableMigration, 23 | tablesColumnMigration, 24 | tablesMigration, 25 | }; 26 | -------------------------------------------------------------------------------- /generators/migrations/renameEnum/index.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { NOT_EMPTY_REGEX } from '@generators/regexes'; 3 | 4 | /** 5 | * Rename an enum from one value to another 6 | */ 7 | export default { 8 | configure: ({ oldName, columnName, modelTableName, model }) => { 9 | const newName = `enum_${modelTableName}_${columnName}`; 10 | return { 11 | name: `rename-${oldName}-to-${newName}`, 12 | newName, 13 | comment: `Rename the enum definition for ${model}.${columnName} to ${newName}`, 14 | }; 15 | }, 16 | description: 'Rename an enum from one value to another', 17 | prompts: { 18 | oldName: { 19 | message: 'What was the old enum name?', 20 | type: 'input', 21 | validate: (value) => 22 | NOT_EMPTY_REGEX.test(value) ? true : 'oldName is required', 23 | }, 24 | }, 25 | type: 'tableColumnMigration', 26 | }; 27 | -------------------------------------------------------------------------------- /generateMixins/helpers/indexPaths.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { CompilerOptions } from 'typescript'; 3 | 4 | /** 5 | * Get the typescript configuration in the directory 6 | * 7 | * @param compilerOptions - The projects compiler options 8 | * @returns Mapping from path prefix to actual location, used to resolve aliased paths 9 | */ 10 | export default function indexPaths( 11 | compilerOptions: CompilerOptions, 12 | ): { [k in string]: string } { 13 | const { paths = {} } = compilerOptions; 14 | return Object.keys(paths).reduce((map, key) => { 15 | const replaceWith = paths[key][0]; 16 | // No true wildcard support, just assuming that there is always 1 wildcard and it's '/*' 17 | // No support for multiple paths per key, either. 18 | const keyPrefix = key.split('/*')[0]; 19 | const path = replaceWith.split('/*')[0]; 20 | return { ...map, [keyPrefix]: path }; 21 | }, {}); 22 | } 23 | -------------------------------------------------------------------------------- /generateMixins/Handlebars.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates the handlebars instance 3 | */ 4 | 5 | // external 6 | import Handlebars from 'handlebars'; 7 | 8 | // helpers 9 | import pascalCase from './helpers/pascalCase'; 10 | 11 | // Set the helpers 12 | Handlebars.registerHelper('pascalCase', pascalCase); 13 | Handlebars.registerHelper('pad', (word: string) => '/'.repeat(word.length)); 14 | Handlebars.registerHelper('ifNotEqual', function ifNotEqual( 15 | this: any, 16 | arg1: string, 17 | arg2: string, 18 | options: Handlebars.HelperOptions, 19 | ) { 20 | return arg1 !== arg2 ? options.fn(this) : options.inverse(this); 21 | }); 22 | 23 | Handlebars.registerHelper('ifEqual', function ifNotEqual( 24 | this: any, 25 | arg1: string, 26 | arg2: string, 27 | options: Handlebars.HelperOptions, 28 | ) { 29 | return arg1 === arg2 ? options.fn(this) : options.inverse(this); 30 | }); 31 | 32 | export default Handlebars; 33 | -------------------------------------------------------------------------------- /generators/migrationTypes/changeEnumAttributes/lib.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the attributes of an enum 3 | */ 4 | export const ENUM_ATTRIBUTE_REGEX = /([a-zA-Z0-9]*)(:| =) '([a-zA-Z0-9]*)',/g; 5 | 6 | /** 7 | * List the attributes of an enum from the content definition 8 | * 9 | * @param content - The enum content 10 | * @param returnValue - Return the enum value (false means return the key) 11 | * @returns The enum attributes 12 | */ 13 | export const listEnumAttributes = function listEnumAttributes( 14 | content: string, 15 | returnValue = true, 16 | ): string[] { 17 | const attributes = []; 18 | let attr = ENUM_ATTRIBUTE_REGEX.exec(content); 19 | while (attr) { 20 | // Save the key or value 21 | const [, key, , value] = attr; 22 | attributes.push(returnValue ? value : key); 23 | 24 | // Get the next 25 | attr = ENUM_ATTRIBUTE_REGEX.exec(content); 26 | } 27 | return attributes; 28 | }; 29 | -------------------------------------------------------------------------------- /generateMixins/helpers/index.ts: -------------------------------------------------------------------------------- 1 | // local 2 | export { default as associationDefinitionToSections } from './associationDefinitionToSections'; 3 | export { default as calculateMixinAttributes } from './calculateMixinAttributes'; 4 | export { default as createBase } from './createBase'; 5 | export { default as createCompilerHost } from './createCompilerHost'; 6 | export { default as extractAssociationDefinitions } from './extractAssociationDefinitions'; 7 | export { default as determineModelToExtend } from './determineModelToExtend'; 8 | export { default as getCompilerOptions } from './getCompilerOptions'; 9 | export { default as getKeys } from './getKeys'; 10 | export { default as indexPaths } from './indexPaths'; 11 | export { default as pascalCase } from './pascalCase'; 12 | export { default as serializeAssociationType } from './serializeAssociationType'; 13 | export { default as setConfigDefaults } from './setConfigDefaults'; 14 | -------------------------------------------------------------------------------- /generators/migrations/rename-enum-value/index.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { NOT_EMPTY_REGEX } from '@generators/regexs'; 3 | 4 | /** 5 | * Rename an enum value in place 6 | */ 7 | export default { 8 | configure: ({ enumName, oldValue, newValue }) => ({ 9 | name: `rename-enum-${enumName}-value-${oldValue}-to-${newValue}`, 10 | comment: `Rename ${oldValue} to ${newValue} on enum ${enumName}`, 11 | }), 12 | description: 'Rename an enum value in place', 13 | type: 'alterEnum', 14 | prompts: { 15 | oldValue: { 16 | message: 'What is the old value?', 17 | type: 'input', 18 | validate: (value) => 19 | NOT_EMPTY_REGEX.test(value) ? true : 'oldValue is required', 20 | }, 21 | newValue: { 22 | message: 'What is the new value?', 23 | type: 'input', 24 | validate: (value) => 25 | NOT_EMPTY_REGEX.test(value) ? true : 'newValue is required', 26 | }, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/models/migration/definition.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { DataTypes } from 'sequelize'; 3 | 4 | // global 5 | import createAttributes from '@wildebeest/utils/createAttributes'; 6 | 7 | // local 8 | import hooks from './hooks'; 9 | 10 | /** 11 | * Attribute definitions for the model 12 | */ 13 | const attributes = createAttributes({ 14 | /** The id is an integer */ 15 | id: { 16 | autoIncrement: true, 17 | primaryKey: true, 18 | type: DataTypes.INTEGER, 19 | }, 20 | /** The batch the migration was run in */ 21 | batch: { 22 | allowNull: true, 23 | type: DataTypes.INTEGER, 24 | }, 25 | /** The name of the migration that has been run */ 26 | name: { 27 | allowNull: false, 28 | type: DataTypes.STRING, 29 | unique: 'name must be unique', 30 | }, 31 | }); 32 | 33 | // Model definition 34 | export default { 35 | attributes, 36 | options: { hooks }, 37 | tableName: 'migrations', 38 | }; 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["esnext", "dom", "es2015"], 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "removeComments": false, 10 | "esModuleInterop": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": ".", 17 | "paths": { 18 | "@generators/*": ["./generators*"], 19 | "@wildebeest*": ["./src*"] 20 | }, 21 | "outDir": "./build", 22 | "types": ["node", "mocha"], 23 | "strict": true 24 | }, 25 | "include": [ 26 | "src/**/*", 27 | "scripts/**/*", 28 | "generateMixins/**/*" 29 | // "generators/**/*" TODO 30 | ], 31 | "exclude": ["node_modules", "build", "docs", "Internal-Documentation"] 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/expectedColumnNames.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { Associations, Attributes } from '@wildebeest/types'; 3 | 4 | // local 5 | import getForeignKeyName from './getForeignKeyName'; 6 | 7 | /** 8 | * Determine the expected column names for a model definition 9 | * 10 | * @param attributes - The sequelize db model attribute definitions 11 | * @param associations - The associations for that db model 12 | * @returns The expected column names 13 | */ 14 | export default function expectedColumnNames( 15 | attributes: Attributes = {}, 16 | associations: Associations = {}, 17 | ): string[] { 18 | const { belongsTo = {} } = associations; 19 | return [ 20 | // The attributes 21 | ...Object.keys(attributes), 22 | // The belongs to columns 23 | ...Object.keys(belongsTo).map((associationName) => 24 | getForeignKeyName(belongsTo[associationName], `${associationName}Id`), 25 | ), 26 | ]; 27 | } 28 | -------------------------------------------------------------------------------- /src/migrationTypes/init.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { MigrationDefinition, ModelMap } from '@wildebeest/types'; 3 | 4 | // local 5 | import custom from './custom'; 6 | 7 | /** 8 | * Options for init migration 9 | */ 10 | export type InitMigrationOptions = { 11 | /** The genesis migration to begin from TODO this should be optional and a genesis can be created with lock/migration tables */ 12 | genesisSchema: string; 13 | }; 14 | 15 | /** 16 | * The initial migration to begin all migrations 17 | * 18 | * @param options - Init migration options 19 | * @returns The init migrator 20 | */ 21 | export default function init({ 22 | genesisSchema, 23 | }: InitMigrationOptions): MigrationDefinition { 24 | return custom({ 25 | up: (_, wildebeest) => 26 | wildebeest.runWithLock((lock) => lock.restoreSchema(genesisSchema)), 27 | down: (_, wildebeest) => wildebeest.runWithLock((lock) => lock.dropAll()), 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /generators/migrations/changeEnumColumn/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migrate an enum column 3 | */ 4 | export default { 5 | configure: ({ columnName, modelTableName, model }) => ({ 6 | name: `change-enum-column-${columnName}-${modelTableName}`, 7 | comment: `Change enum column ${columnName} on table ${model}`, 8 | }), 9 | description: 'Migrate an enum column', 10 | prompts: { 11 | oldAttributes: { 12 | message: 'What are the old enum attributes?', 13 | extension: 'hbs', 14 | type: 'editor', 15 | }, 16 | newAttributes: { 17 | message: 'What are the new enum attributes?', 18 | extension: 'hbs', 19 | type: 'editor', 20 | }, 21 | oldDefault: { 22 | message: 'What is the old default value?', 23 | type: 'input', 24 | }, 25 | newDefault: { 26 | message: 'What is the new default value?', 27 | type: 'input', 28 | }, 29 | }, 30 | type: 'tableColumnMigration', // TODO tablesColumnMigration 31 | }; 32 | -------------------------------------------------------------------------------- /src/utils/getAssociationColumnName.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { Association } from '@wildebeest/types'; 3 | 4 | /** 5 | * Get the name of the column for a belongsTo association 6 | * 7 | * @param association - The association config 8 | * @param name - The name of the association 9 | * @returns The name of the column 10 | */ 11 | export default function getAssociationColumnName( 12 | association: Association, 13 | associationName: string, 14 | ): string { 15 | // Return name when specified 16 | if ( 17 | typeof association === 'object' && 18 | typeof association.foreignKey === 'string' 19 | ) { 20 | return association.foreignKey; 21 | } 22 | if ( 23 | typeof association === 'object' && 24 | typeof association.foreignKey === 'object' && 25 | association.foreignKey.name 26 | ) { 27 | return association.foreignKey.name; 28 | } 29 | 30 | // Return the default 31 | return `${associationName}Id`; 32 | } 33 | -------------------------------------------------------------------------------- /generators/migrations/renameColumn/index.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { NOT_EMPTY_REGEX } from '@generators/regexs'; 3 | 4 | /** 5 | * Create a migration definition that renames a column 6 | */ 7 | export default { 8 | configure: ({ modelTableName, newName, oldName, model }) => ({ 9 | name: `rename-column-${modelTableName}-to-${newName}`, 10 | comment: `Rename the column ${oldName} to ${newName} on table ${model}`, 11 | }), 12 | description: 'Create a migration definition that renames a column', 13 | prompts: { 14 | oldName: { 15 | message: 'What was the old column name?', 16 | type: 'input', 17 | validate: (value) => 18 | NOT_EMPTY_REGEX.test(value) ? true : 'oldName is required', 19 | }, 20 | newName: { 21 | message: 'What is the new column name?', 22 | type: 'input', 23 | validate: (value) => 24 | NOT_EMPTY_REGEX.test(value) ? true : 'newName is required', 25 | }, 26 | }, 27 | type: 'tableMigration', 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/listColumns.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | // external 3 | import { QueryTypes } from 'sequelize'; 4 | 5 | // global 6 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 7 | import { ModelMap } from '@wildebeest/types'; 8 | 9 | /** 10 | * The top level metadata for a column in the db 11 | */ 12 | export type ListedColumn = { 13 | /** Name of column */ 14 | column_name: string; 15 | /** Type of column */ 16 | data_type: string; 17 | }; 18 | 19 | /** 20 | * List the columns that the table currently has 21 | * 22 | * @returns The current table columns 23 | */ 24 | export default function listColumns( 25 | db: WildebeestDb, 26 | tableName: string, 27 | ): Promise { 28 | return db 29 | .query( 30 | `SELECT column_name,data_type from information_schema.columns WHERE table_name='${tableName}'`, 31 | { type: QueryTypes.SELECT }, 32 | ) 33 | .then((columns) => 34 | (columns as ListedColumn[]).map(({ column_name }) => column_name), 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/views/index.hbs: -------------------------------------------------------------------------------- 1 |

Database Migrations

2 | 3 |

CURRENT   |   WIPE&CURRENT   |   EMPTY   |   WIPE&TEST ALL |   TEST SYNC

4 | 5 |
    6 | {{#each migrations}} 7 |
  • 8 |

    9 | {{#if isRun }} 10 | DOWN 11 | {{else}} 12 | UP 13 | {{/if}} 14 |    15 | {{name}} 16 |

    17 |
  • 18 | {{/each}} 19 |
20 |
21 |

22 |     23 | {{#if hasBackPage}} 24 | Back   25 | {{/if}} 26 | {{#if hasNextPage}} 27 | Next 28 | {{/if}} 29 |

30 | -------------------------------------------------------------------------------- /src/utils/dropEnum.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 3 | import { 4 | Attributes, 5 | MigrationTransactionOptions, 6 | ModelMap, 7 | } from '@wildebeest/types'; 8 | 9 | /** 10 | * Drop an enum from the db 11 | * 12 | * @param db - The database to drop the enum from 13 | * @param name - The name of the enum in the db to remove 14 | * @param rawTransactionOptions - The existing transaction 15 | * @param cascade - Cascade result to other references 16 | * @returns The drop enum promise 17 | */ 18 | export default async function dropEnum< 19 | TModels extends ModelMap, 20 | TAttributes extends Attributes 21 | >( 22 | db: WildebeestDb, 23 | name: string, 24 | transactionOptions?: MigrationTransactionOptions, 25 | cascade?: boolean, 26 | ): Promise { 27 | // Raw query interface 28 | const { queryInterface } = db; 29 | 30 | // Drop the enum 31 | await queryInterface.sequelize.query( 32 | `DROP type IF EXISTS "${name}" ${cascade ? 'CASCADE' : ''};`, 33 | transactionOptions, 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/restoreFromDump.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { execSync } from 'child_process'; 3 | 4 | // global 5 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 6 | import { ModelMap } from '@wildebeest/types'; 7 | 8 | /** 9 | * Restore the database from a pd_dump output 10 | * 11 | * @param wildebeest - The wildebeest instance to restore a dump to 12 | * @param name - The name of the schema that was written 13 | * @param schemaPath - The path to the schema folder 14 | * @returns The pg_restore promise 15 | */ 16 | export default async function restoreFromDump( 17 | wildebeest: Wildebeest, 18 | name: string, 19 | ): Promise { 20 | if (!wildebeest.schemaExists(name)) { 21 | throw new Error(`Schema definition not found: "${name}"`); 22 | } 23 | await Promise.resolve( 24 | execSync( 25 | `pg_restore -d "${ 26 | wildebeest.db.databaseUri 27 | }" -n public -C ${wildebeest.getSchemaFile(name)}`, 28 | ), 29 | ); 30 | // Log success 31 | wildebeest.logger.warn(`\nRestored database schema dump: "${name}"\n`); 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration files for migrating the db using Sequelize and Umzug 3 | */ 4 | 5 | // external 6 | import { DataTypes } from 'sequelize'; 7 | 8 | // local 9 | import * as checks from './checks'; 10 | import Wildebeest, { 11 | NamingConventions, 12 | WildebeestOptions, 13 | } from './classes/Wildebeest'; 14 | import { CASCADE_HOOKS, NON_NULL } from './constants'; 15 | import * as utils from './utils'; 16 | 17 | // expose all migration types and classes 18 | export * from './classes'; 19 | export * from './migrationTypes'; 20 | 21 | // Types 22 | export * from './models'; 23 | export * from './mixins/types'; 24 | export * from './types'; 25 | export { default as Logger } from './Logger'; 26 | export const { 27 | createAssociationApply, 28 | createHooks, 29 | createAssociationApplyValue, 30 | createAttributes, 31 | escapeJson, 32 | } = utils; 33 | export { 34 | DataTypes, 35 | checks, 36 | utils, 37 | CASCADE_HOOKS, 38 | NON_NULL, 39 | Wildebeest, 40 | NamingConventions, 41 | WildebeestOptions, 42 | }; 43 | 44 | // Default is wildebeest class 45 | export default Wildebeest; 46 | -------------------------------------------------------------------------------- /generateMixins/helpers/getCompilerOptions.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import * as fs from 'fs'; 3 | import { join } from 'path'; 4 | import { CompilerOptions } from 'typescript'; 5 | 6 | /** 7 | * Get the typescript configuration in the directory 8 | * 9 | * @param directory - The directory to look for the tsconfig in 10 | * @param tsConfigPath - The path to the tsconfig if not in the expected tsconfig.json location 11 | * @returns The tsconfig or throws an error 12 | */ 13 | export default function getCompilerOptions( 14 | directory: string, 15 | tsConfigPath = join(directory, 'tsconfig.json'), 16 | ): CompilerOptions { 17 | // ensure the file exists 18 | if (!fs.existsSync(tsConfigPath)) { 19 | throw new Error(`Path to tsconfig does not exist: "${tsConfigPath}"`); 20 | } 21 | 22 | // Remove single-line comments. 23 | // Comments are allowed in tsconfig.json, but not allowed in standard json format, which `JSON.parse` expects. 24 | const tscJson = fs 25 | .readFileSync(tsConfigPath, 'utf-8') 26 | .replace(/\/\/.*?\n|\/\* .*?\n/g, ''); 27 | 28 | // Parse to JSON 29 | return JSON.parse(tscJson).compilerOptions as CompilerOptions; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/indexConstraints.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import keyBy from 'lodash/keyBy'; 3 | 4 | /** 5 | * Foreign key constraint definition for a table column 6 | */ 7 | export type ColumnConstraint = { 8 | /** The name of the column */ 9 | columnName: string; 10 | /** The name of foreign the table */ 11 | tableName?: string; 12 | /** The name of the foreign key column name */ 13 | foreignColumnName?: string; 14 | }; 15 | 16 | /** 17 | * Shorthand constraint input is to specify columnName only and use the default table name (removing `Id`) 18 | */ 19 | export type RawConstraint = string | ColumnConstraint; 20 | 21 | /** 22 | * Index foreign key constraints by name of column 23 | */ 24 | export default function indexConstraints( 25 | constraints: RawConstraint[], 26 | ): { [columnName in string]: ColumnConstraint } { 27 | // Index constraints so the can be looked up by columnName 28 | const columnConstraints: ColumnConstraint[] = constraints.map((constraint) => 29 | typeof constraint === 'string' 30 | ? { 31 | columnName: constraint, 32 | } 33 | : constraint, 34 | ); 35 | return keyBy(columnConstraints, 'columnName'); 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/getAssociationAttribute.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import * as sequelize from 'sequelize'; 3 | 4 | // global 5 | import { Association } from '@wildebeest/types'; 6 | 7 | /** 8 | * Get the attribute definition for an association column 9 | * 10 | * @param association - The association config 11 | * @param config - The attribute config of the column to join on 12 | * @returns The association attribute definition 13 | */ 14 | export default function getAssociationAttribute( 15 | association: Association, 16 | config: sequelize.ModelAttributeColumnOptions = { 17 | defaultValue: sequelize.UUIDV4, 18 | type: sequelize.UUID, 19 | }, 20 | ): sequelize.ModelAttributeColumnOptions { 21 | // We do not copy over some values 22 | const { primaryKey, allowNull, unique, defaultValue, ...rest } = config; // eslint-disable-line @typescript-eslint/no-unused-vars,max-len 23 | return { 24 | ...rest, 25 | allowNull: !!( 26 | typeof association === 'object' && 27 | (!association.foreignKey || 28 | (typeof association.foreignKey === 'object' && 29 | association.foreignKey.allowNull)) 30 | ), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/listIndexNames.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { QueryTypes } from 'sequelize'; 3 | 4 | // global 5 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 6 | import { MigrationTransactionOptions, ModelMap } from '@wildebeest/types'; 7 | 8 | /** 9 | * A db index 10 | */ 11 | export type PgIndex = { 12 | /** The name of the index */ 13 | indexname: string; 14 | }; 15 | 16 | /** 17 | * List indexes related to a table 18 | * 19 | * @param db - The db instance to check 20 | * @param tableName - The table to list indexes for 21 | * @returns The names of the indexes 22 | */ 23 | export default async function listIndexNames( 24 | db: WildebeestDb, 25 | tableName: string, 26 | transactionOptions?: MigrationTransactionOptions, 27 | ): Promise { 28 | const { queryInterface } = db; 29 | // Determine the indexes 30 | const indexes: PgIndex[] = await queryInterface.sequelize.query( 31 | `select * from pg_indexes where tableName='${tableName}'`, 32 | { 33 | ...transactionOptions, 34 | type: QueryTypes.SELECT, 35 | }, 36 | ); 37 | return indexes.map(({ indexname }) => indexname); 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/columnAllowsNull.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { QueryTypes } from 'sequelize'; 3 | 4 | // global 5 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 6 | import { MigrationTransactionOptions, ModelMap } from '@wildebeest/types'; 7 | 8 | /** 9 | * Check if a postgres column allows null values 10 | * 11 | * @param db - The db instance to use 12 | * @param tableName - The name of the table to check 13 | * @param columnName - The name of the column 14 | * @returns True if null values are allowed 15 | */ 16 | export default async function columnAllowsNull( 17 | db: WildebeestDb, 18 | tableName: string, 19 | columnName: string, 20 | transactionOptions?: MigrationTransactionOptions, 21 | ): Promise { 22 | const [row] = await db.queryInterface.sequelize.query( 23 | ` 24 | SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT 25 | FROM INFORMATION_SCHEMA.COLUMNS 26 | WHERE table_name = '${tableName}' AND is_nullable = 'YES' AND column_name = '${columnName}' 27 | `, 28 | { 29 | type: QueryTypes.SELECT, 30 | ...transactionOptions, 31 | }, 32 | ); 33 | return !!row; 34 | } 35 | -------------------------------------------------------------------------------- /generators/migrations/changeOnDelete/index.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import kebabCase from 'lodash/kebabCase'; 3 | 4 | // global 5 | import { OnDelete } from '@wildebeest/types'; 6 | 7 | /** 8 | * Change the onDelete status of a foreign key constraint 9 | */ 10 | export default { 11 | configure: ({ 12 | modelTableName, 13 | model, 14 | columnName, 15 | newOnDelete, 16 | oldOnDelete, 17 | }) => ({ 18 | name: `change-on-delete-${modelTableName}-${columnName}-to-${kebabCase( 19 | newOnDelete, 20 | )}`, 21 | comment: `Change the foreign key constraint for column ${model}.${columnName} from ${oldOnDelete} to ${newOnDelete}`, 22 | }), 23 | description: 'Change the onDelete status of a foreign key constraint', 24 | prompts: { 25 | oldOnDelete: { 26 | message: 'What was the old onDelete value?', 27 | source: () => Object.values(OnDelete), 28 | type: 'autocomplete', 29 | }, 30 | newOnDelete: { 31 | message: 'What is the new onDelete value?', 32 | source: ({ oldOnDelete }) => 33 | Object.values(OnDelete).filter((val) => val !== oldOnDelete), 34 | type: 'autocomplete', 35 | }, 36 | }, 37 | type: 'tableColumnMigration', 38 | }; 39 | -------------------------------------------------------------------------------- /generateMixins/ChangeCase.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import camelCase from 'lodash/camelCase'; 3 | import pluralize from 'pluralize'; 4 | 5 | // helpers 6 | import pascalCase from './helpers/pascalCase'; 7 | 8 | /** 9 | * A class with all of the casings 10 | */ 11 | export default class ChangeCase { 12 | /** The word itself (identity function) */ 13 | public plain: string; 14 | 15 | /** Casing for camelCase */ 16 | public camelCase: string; 17 | 18 | /** Casing for pascalCase */ 19 | public pascalCase: string; 20 | 21 | /** Casing for pluralCase */ 22 | public pluralCase: string; 23 | 24 | /** Casing for pascalPluralCase */ 25 | public pascalPluralCase: string; 26 | 27 | /** 28 | * Create a new case changed 29 | * 30 | * @param word - The word to change the case of 31 | * @param pluralCase - A function that will pluralize a word 32 | */ 33 | public constructor( 34 | word: string, 35 | pluralCase: (word: string) => string = pluralize, 36 | ) { 37 | this.plain = word; 38 | this.camelCase = camelCase(word); 39 | this.pascalCase = pascalCase(word); 40 | this.pluralCase = pluralCase(word); 41 | this.pascalPluralCase = pascalCase(pluralCase(word)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/checks/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Different tests that can be performed to check if sequelize definitions are in sync with the postgres db. 3 | */ 4 | 5 | // local 6 | export { default as checkAllowNullConstraint } from './allowNullConstraint'; 7 | export { default as checkAssociationConfig } from './associationConfig'; 8 | export { default as checkAssociationsSync } from './associations'; 9 | export { default as checkBelongsToAssociation } from './belongsToAssociation'; 10 | export { default as checkColumnDefinition } from './columnDefinition'; 11 | export { default as checkColumnDefinitions } from './columnDefinitions'; 12 | export { default as checkColumnType } from './columnType'; 13 | export { default as checkDefaultValue } from './defaultValue'; 14 | export { default as checkEnumDefinition } from './enumDefinition'; 15 | export { default as checkExtraneousTables } from './extraneousTables'; 16 | export { default as checkJoinBelongsTo } from './joinBelongsTo'; 17 | export { default as checkModel } from './model'; 18 | export { default as checkIndexes } from './indexes'; 19 | export { default as checkPrimaryKeyDefinition } from './primaryKeyDefinition'; 20 | export { default as checkUniqueConstraint } from './uniqueConstraint'; 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "backoff", 4 | "bytea", 5 | "camelcase", 6 | "conname", 7 | "connamespace", 8 | "conrelid", 9 | "constraintdef", 10 | "esnext", 11 | "executables", 12 | "fkey", 13 | "get", 14 | "jsonb", 15 | "koalaman", 16 | "middlewares", 17 | "mimetype", 18 | "mixins", 19 | "new", 20 | "new parens", 21 | "nobuild", 22 | "npmjs", 23 | "npmrc", 24 | "nspname", 25 | "optionalize", 26 | "parens", 27 | "pathlib", 28 | "pg", 29 | "postgres", 30 | "postgresql", 31 | "pyyaml", 32 | "quotemark", 33 | "requirize", 34 | "setuptools", 35 | "shellcheck", 36 | "symlinks", 37 | "types", 38 | "umzug", 39 | "unnested", 40 | "upsert", 41 | "uuidv" 42 | ], 43 | "[handlebars]": { "editor.formatOnSave": false }, 44 | "eslint.validate": [ 45 | "javascript", 46 | "javascriptreact", 47 | "typescript", 48 | "typescriptreact" 49 | ], 50 | "editor.codeActionsOnSave": { 51 | "source.fixAll": true 52 | }, 53 | "files.exclude": { 54 | "**/node_modules": true 55 | }, 56 | "editor.formatOnSave": true, 57 | "typescript.tsdk": "node_modules/typescript/lib" 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/createUmzugLogger.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { LOG_SKIPS } from '@wildebeest/constants'; 3 | import Logger from '@wildebeest/Logger'; 4 | 5 | /** 6 | * Only log every 10th migration in test mode 7 | */ 8 | export const BASE_100_MIGRATIONS_REGEX = /== ([0-9][0-9]00).+?\(.+?s\)/; 9 | 10 | /** 11 | * Create a logging function to use with umzug 12 | * 13 | * @param logger - The logger to use 14 | * @returns A logging function that can be used to initialize umzug 15 | */ 16 | export default function createUmzugLogger( 17 | logger: Logger, 18 | ): (info: string) => void { 19 | return (info: string): void => { 20 | // The logging function to use 21 | const func = 22 | info.includes(': migrated') || info.includes(': reverted') 23 | ? 'info' 24 | : 'debug'; 25 | 26 | // Whether to skip 27 | const skip = LOG_SKIPS.filter((tx) => info.includes(tx)).length > 0; 28 | const isBase100 = BASE_100_MIGRATIONS_REGEX.test(info); 29 | 30 | // Determine logging contents 31 | const txt = !isBase100 32 | ? info.replace('\n', '') 33 | : ` ${(BASE_100_MIGRATIONS_REGEX.exec(info) || [])[1]}`; 34 | 35 | // Log if not skipping 36 | if (!skip) { 37 | logger[func](txt); 38 | } 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/listEnumAttributes.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { QueryTypes } from 'sequelize'; 3 | 4 | // global 5 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 6 | import { MigrationTransactionOptions, ModelMap } from '@wildebeest/types'; 7 | 8 | /** 9 | * Unnested Value 10 | */ 11 | export type UnnestedAttr = { 12 | /** The name of the attribute */ 13 | unnest: string; 14 | }; 15 | 16 | /** 17 | * List indexes related to a table 18 | * 19 | * @param db - The database to list enum values from 20 | * @param name - The name of the enum to list the values from 21 | * @param rawTransactionOptions - The transaction options 22 | * @returns The enum attributes 23 | */ 24 | export default async function listEnumAttributes( 25 | db: WildebeestDb, 26 | name: string, 27 | transactionOptions?: MigrationTransactionOptions, 28 | ): Promise { 29 | // Raw query interface 30 | const { queryInterface } = db; 31 | 32 | // Determine the indexes 33 | const values: UnnestedAttr[] = await queryInterface.sequelize.query( 34 | `SELECT unnest(enum_range(NULL::"${name}"))`, 35 | { ...transactionOptions, type: QueryTypes.SELECT }, 36 | ); 37 | return values.map(({ unnest }) => unnest); 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/getColumnDefault.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { QueryTypes } from 'sequelize'; 3 | 4 | // global 5 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 6 | import { MigrationTransactionOptions, ModelMap } from '@wildebeest/types'; 7 | 8 | /** 9 | * Get the default value that the column takes on 10 | * 11 | * @param db - The db to check 12 | * @param tableName - The name of the table to check 13 | * @param columnName - The name of the column 14 | * @returns The default value 15 | */ 16 | export default async function getColumnDefault( 17 | db: WildebeestDb, 18 | tableName: string, 19 | columnName: string, 20 | transactionOptions?: MigrationTransactionOptions, 21 | ): Promise { 22 | // Get the default for the column 23 | const [ 24 | { column_default: columnDefault }, 25 | ] = await db.queryInterface.sequelize.query( 26 | ` 27 | SELECT column_name, column_default 28 | FROM information_schema.columns 29 | WHERE (table_schema, table_name, column_name) = ('public', '${tableName}', '${columnName}') 30 | ORDER BY ordinal_position; 31 | `, 32 | { 33 | ...transactionOptions, 34 | type: QueryTypes.SELECT, 35 | }, 36 | ); 37 | 38 | return columnDefault; 39 | } 40 | -------------------------------------------------------------------------------- /src/migrationTypes/uuidNonNull.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { MigrationDefinition, ModelMap } from '@wildebeest/types'; 3 | 4 | // local 5 | import changeColumn from './changeColumn'; 6 | 7 | /** 8 | * Options for making a UUID column non null 9 | */ 10 | export type UUIDNonNullOptions = { 11 | /** The name of the table to modify */ 12 | tableName: string; 13 | /** The name of the uuid column */ 14 | columnName: string; 15 | /** When true, drop all null values */ 16 | destroy?: boolean; 17 | }; 18 | 19 | /** 20 | * Make a uuid column non null 21 | * 22 | * @param options - Options for making the uuid column non-null 23 | * @returns The uuid non null migrator 24 | */ 25 | export default function uuidNonNull( 26 | options: UUIDNonNullOptions, 27 | ): MigrationDefinition { 28 | const { tableName, columnName, destroy = false } = options; 29 | return changeColumn({ 30 | tableName, 31 | columnName, 32 | getOldColumn: ({ DataTypes }) => ({ 33 | allowNull: true, 34 | defaultValue: DataTypes.UUIDV4, 35 | type: DataTypes.UUID, 36 | }), 37 | getNewColumn: ({ DataTypes }) => ({ 38 | allowNull: false, 39 | defaultValue: DataTypes.UUIDV4, 40 | type: DataTypes.UUID, 41 | }), 42 | destroy, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /generators/migrationTypes/alterEnum/index.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import ModelPath from '@generators/prompts/modelPath'; 3 | 4 | // migrationTypes 5 | import { listAllAttributeConfigs } from '../tableColumnMigration/lib'; 6 | 7 | /** 8 | * A higher order generator that creates a Alter enum migrator. 9 | * 10 | * Alter an existing enum 11 | */ 12 | export default { 13 | parentType: 'tableMigration', 14 | prompts: { 15 | enumName: (repo) => ({ 16 | message: 'Which enum are you modifying?', 17 | source: ({ modelPath }) => 18 | listAllAttributeConfigs(repo, modelPath) 19 | .filter( 20 | ({ config }) => config && config.content.includes('DataTypes.ENUM'), 21 | ) 22 | .map( 23 | ({ columnName }) => 24 | `enum_${pluralCase( 25 | ModelPath.getModelName(repo, modelPath), 26 | )}_${columnName}`, 27 | ), 28 | type: 'autocomplete', 29 | postProcess: ({ enumName }) => { 30 | const split = enumName.split('_'); 31 | // TODO 32 | const columnName = split.pop(); 33 | const tableName = split.slice(1, split.length).join('_'); 34 | return { 35 | columnName, 36 | tableName, 37 | }; 38 | }, 39 | }), 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/utils/writeSchema.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { execSync } from 'child_process'; 3 | import { copyFileSync } from 'fs'; 4 | 5 | // global 6 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 7 | import { ModelMap } from '@wildebeest/types'; 8 | 9 | /** 10 | * Write the current database schema to a file 11 | * 12 | * Only will work when RUN apk add --no-cache postgresql-client=10.5-r0 is not commented out in app.Dockerfile 13 | * 14 | * @param wildebeest - The wildebeest db migrator config 15 | * @param name - The name of the schema file 16 | * @returns The schema written 17 | */ 18 | export default async function writeSchema( 19 | wildebeest: Wildebeest, 20 | name: string, 21 | ): Promise { 22 | // Hold the output of the sql 23 | const writePath = wildebeest.getSchemaFile(name); 24 | await Promise.resolve( 25 | execSync(`pg_dump -Fc ${wildebeest.db.databaseUri} > ${writePath}`), 26 | ); 27 | 28 | // Copy to src so that it can be synced 29 | // TODO custom to transcend remove this eventually 30 | if (writePath.includes('/build/backend/src/')) { 31 | const srcPath = writePath.replace('/build/backend/src/', '/src/'); 32 | copyFileSync(writePath, srcPath); 33 | } 34 | 35 | // Log success 36 | wildebeest.logger.warn(`\nWrote schema to dump: "${name}"\n`); 37 | } 38 | -------------------------------------------------------------------------------- /src/models/migration/hooks.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { ModelMap } from '@wildebeest/types'; 3 | import createHooks from '@wildebeest/utils/createHooks'; 4 | 5 | // local 6 | import type Migration from './Migration'; 7 | 8 | const getNumber = (name: string): number => 9 | parseInt(name.match(/^\d+/) as any, 10); 10 | 11 | /** 12 | * The default model hooks 13 | */ 14 | export default createHooks>({ 15 | /** 16 | * Updates the batch number of a new migration 17 | * 18 | * @param migration - The newly created migration 19 | */ 20 | afterCreate: async (migration) => { 21 | const { batch } = migration.constructor as typeof Migration; 22 | await migration.update({ batch }); 23 | }, 24 | /** 25 | * The pre hook before the model is destroyed. 26 | * 27 | * 1) Updates the batch number of any new migration 28 | * 29 | * @param migration - The newly created migration 30 | */ 31 | beforeDestroy: async (migration) => { 32 | const index = getNumber(migration.name); 33 | 34 | await (migration.constructor as typeof Migration) 35 | .findAll() 36 | .then((migrations) => 37 | migrations.filter(({ name }) => getNumber(name) >= index), 38 | ) 39 | .then((migrations) => 40 | Promise.all(migrations.map((mig) => mig.destroy())), 41 | ); 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /src/utils/apply.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { ObjByString } from '@wildebeest/types'; 3 | 4 | /** 5 | * The function to apply 6 | */ 7 | export type ApplyFunc< 8 | TInput extends ObjByString, 9 | TOutput, 10 | TObjType extends TInput 11 | > = ( 12 | value: TInput[keyof TInput], 13 | key: Extract, 14 | fullObj: TObjType, 15 | index: number, 16 | ) => TOutput; 17 | 18 | /** 19 | * Apply a function to each value of an object. Similar to lodash.mapValues but should preserver the typing of the keys. 20 | * 21 | * This allows one to define an object keys in an enum and then the resulting map should keep the same typing 22 | * 23 | * @param obj - The object to apply the function to 24 | * @param applyFunc - The function to apply 25 | * @returns The updated object 26 | */ 27 | export default function apply( 28 | obj: TInput, 29 | applyFunc: ApplyFunc, 30 | ): { [key in keyof TInput]: TOutput } { 31 | const result = Object.keys(obj).reduce( 32 | (acc, key, ind) => 33 | Object.assign(acc, { 34 | [key]: applyFunc( 35 | obj[key], 36 | key as Extract, 37 | obj, 38 | ind, 39 | ), 40 | }), 41 | {}, 42 | ); 43 | return result as { [key in keyof TInput]: TOutput }; 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/createIndex.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 3 | import { MigrationTransactionOptions, ModelMap } from '@wildebeest/types'; 4 | 5 | /** 6 | * Options for creating an index 7 | * @see http://docs.sequelizejs.com/class/lib/query-interface.js~QueryInterface.html#instance-method-addIndex 8 | */ 9 | export type CreateIndexOptions = { 10 | /** The table to create the index in */ 11 | tableName: string; 12 | /** The attributes to create the index on */ 13 | fields: string[]; 14 | /** Whether the index is a unique one */ 15 | unique?: boolean; 16 | }; 17 | 18 | /** 19 | * Creates a new index, main purpose is to override @types/sequelize 20 | * 21 | * @param db - The db to create the index in 22 | * @param name - Tha name of the index 23 | * @param options - The index options 24 | * @param transactionOptions - The transaction options 25 | */ 26 | export default async function createIndex( 27 | db: WildebeestDb, 28 | name: string, 29 | options: CreateIndexOptions, 30 | transactionOptions?: MigrationTransactionOptions, 31 | ): Promise { 32 | const { tableName, fields, ...rest } = options; 33 | await db.queryInterface.addIndex(tableName, fields, { 34 | fields, 35 | name, 36 | ...rest, 37 | ...transactionOptions, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /generateMixins/helpers/setConfigDefaults.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { join } from 'path'; 3 | import pluralize from 'pluralize'; 4 | 5 | // mixins 6 | import { GenerateMixinsConfig } from '../types'; 7 | 8 | // local 9 | import defaultDetermineModelToExtend from './determineModelToExtend'; 10 | 11 | /** 12 | * Define all properties of config 13 | * 14 | * @param config - The raw config 15 | * @returns The keys of the object preserving type 16 | */ 17 | export default function setConfigDefaults({ 18 | getCustomMixinAttributes = {}, 19 | pluralCase = pluralize, 20 | singularCase = pluralize.singular, 21 | baseFileName = 'Base.ts', 22 | associationFileName = 'definition.ts', 23 | associationDefinitionName = 'associations', 24 | tsConfigPath, 25 | baseModelTemplatePath = join(__dirname, '..', 'base.hbs'), 26 | determineModelToExtend = defaultDetermineModelToExtend, 27 | handlebarsHelpers = {}, 28 | ...rest 29 | }: GenerateMixinsConfig): Required { 30 | return { 31 | ...rest, 32 | handlebarsHelpers, 33 | determineModelToExtend, 34 | baseFileName, 35 | getCustomMixinAttributes, 36 | singularCase, 37 | pluralCase, 38 | baseModelTemplatePath, 39 | tsConfigPath: tsConfigPath || join(rest.rootPath, 'tsconfig.json'), 40 | associationFileName, 41 | associationDefinitionName, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/inferTableReference.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import camelCase from 'lodash/camelCase'; 3 | 4 | /** 5 | * The foreign key table reference the constraint should be made with 6 | */ 7 | export type TableReference = { 8 | /** The name of the table */ 9 | table: string; 10 | /** The field aka columnName in the table to reference */ 11 | field: string; 12 | }; 13 | 14 | /** 15 | * Infer the default reference fields from a column 16 | * 17 | * @example Example usage of inferTableReference 18 | * // { field: 'id', table: 'organizations' } 19 | * inferTableReference('organizationId'); 20 | * 21 | * @param column - The name of the column to add the cascade to 22 | * @param modelNameToTableName - A function that will convert a model name to table name 23 | * @returns The table and field extracted from the column 24 | */ 25 | export default function inferTableReference( 26 | column: string, 27 | modelNameToTableName: (word: string) => string, 28 | ): TableReference { 29 | // Split the column by camel case 30 | const splitColumn = column.split(/(?=[A-Z])/); 31 | 32 | // The reference table field is by default the camel case version of the camel case word 33 | const field = camelCase(splitColumn.pop()); 34 | 35 | // Determine the name of the reference table 36 | const table = modelNameToTableName(splitColumn.join('')); 37 | 38 | return { table, field }; 39 | } 40 | -------------------------------------------------------------------------------- /src/views/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handlebars templates used for the running wildebeest migrations. 3 | */ 4 | 5 | // external 6 | import { readFileSync } from 'fs'; 7 | import Handlebars from 'handlebars'; 8 | 9 | // helpers 10 | import type { ObjByString } from '@wildebeest/types'; 11 | import pascalCase from '@wildebeest/utils/pascalCase'; 12 | import { join } from 'path'; 13 | 14 | // Set the helpers 15 | Handlebars.registerHelper('pascalCase', pascalCase); 16 | Handlebars.registerHelper('pad', (word: string) => '/'.repeat(word.length)); 17 | Handlebars.registerHelper('ifNotEqual', function ifNotEqual( 18 | this: any, 19 | arg1: string, 20 | arg2: string, 21 | options: Handlebars.HelperOptions, 22 | ) { 23 | return arg1 !== arg2 ? options.fn(this) : options.inverse(this); 24 | }); 25 | 26 | const TEMPLATES = { 27 | error: readFileSync(join(__dirname, 'error.hbs'), 'utf-8'), 28 | index: readFileSync(join(__dirname, 'index.hbs'), 'utf-8'), 29 | success: readFileSync(join(__dirname, 'success.hbs'), 'utf-8'), 30 | }; 31 | 32 | /** 33 | * Render some HTML 34 | * 35 | * @param name - The name of the template to use 36 | * @param params - The handlebars compilation params 37 | * @returns The HTML template 38 | */ 39 | export default function render( 40 | name: keyof typeof TEMPLATES, 41 | params: ObjByString, 42 | ): string { 43 | return Handlebars.compile(TEMPLATES[name])(params); 44 | } 45 | -------------------------------------------------------------------------------- /generateMixins/helpers/determineModelToExtend.ts: -------------------------------------------------------------------------------- 1 | // mixins 2 | import { 3 | AssociationsDefinition, 4 | BaseFileInput, 5 | GenerateMixinsConfig, 6 | } from '../types'; 7 | 8 | // local 9 | import pascalCase from './pascalCase'; 10 | 11 | /** 12 | * Determine the model that the `Base` should extend 13 | * 14 | * @param directory - The directory to look for the tsconfig in 15 | * @param tsConfigPath - The path to the tsconfig if not in the expected tsconfig.json location 16 | * @returns The tsconfig or throws an error 17 | */ 18 | export default function determineModelToExtend( 19 | associationsDefinition: AssociationsDefinition, 20 | config: Required, 21 | ): Omit { 22 | // Get te file path with 23 | const split = associationsDefinition.filePath.split('/'); 24 | if (split[split.length - 1] === config.associationFileName) { 25 | split.pop(); 26 | } 27 | 28 | const [modelBaseFolderRaw, container] = split.slice(-2); 29 | const modelBaseFolder = ['src', 'models'].includes(modelBaseFolderRaw) 30 | ? 'db' 31 | : modelBaseFolderRaw; 32 | const ModelBaseName = 33 | modelBaseFolder === 'db' 34 | ? 'Model' 35 | : `${pascalCase(config.singularCase(modelBaseFolder))}Model`; 36 | 37 | return { 38 | container, 39 | ModelBaseName, 40 | modelBaseFolder, 41 | modelDefinitionsPath: config.modelDefinitionsPath, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/getAssociationsByModelName.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BelongsToAssociation, 3 | HasManyAssociation, 4 | HasOneAssociation, 5 | } from '@wildebeest/types'; 6 | 7 | // local 8 | import getKeys from './getKeys'; 9 | 10 | /** 11 | * No belongs to many 12 | */ 13 | export type AssociationWithoutBelongsToMany = 14 | | BelongsToAssociation 15 | | HasManyAssociation 16 | | HasOneAssociation; 17 | 18 | /** 19 | * Get the association definitions for a given model name 20 | * 21 | * @param modelName - The name of the model to check for 22 | * @param associations - The association lookup to check for model name in 23 | * @returns The keys of the object preserving type 24 | */ 25 | export default function getAssociationsByModelName( 26 | modelName: TModelNames, 27 | associations: { 28 | [k in string]: AssociationWithoutBelongsToMany; 29 | }, 30 | ): AssociationWithoutBelongsToMany[] { 31 | return getKeys(associations) 32 | .filter((associationName) => { 33 | const association = associations[associationName]; 34 | const associationModelName = 35 | typeof association === 'object' && association.modelName 36 | ? association.modelName 37 | : associationName; 38 | return associationModelName === modelName; 39 | }) 40 | .map((associationName) => associations[associationName]); 41 | } 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See http://pre-commit.com for more information 2 | # See http://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | sha: v1.3.0 6 | hooks: 7 | - id: check-merge-conflict 8 | - id: check-symlinks 9 | - id: check-case-conflict 10 | - id: check-executables-have-shebangs 11 | - id: detect-private-key 12 | exclude: (ssl/) 13 | - id: detect-aws-credentials 14 | args: [--allow-missing-credentials] 15 | - id: check-yaml 16 | files: (yaml$|yml$) 17 | - repo: local 18 | hooks: 19 | - id: shellcheck-lint 20 | name: Shellcheck 21 | language: docker_image 22 | # https://github.com/koalaman/shellcheck 23 | entry: koalaman/shellcheck:v0.5.0 24 | types: [shell] 25 | - id: eslint 26 | name: ESLint 27 | language: system 28 | entry: ./node_modules/.bin/eslint 29 | args: [--fix] 30 | types: [file] 31 | files: \.(js|jsx|ts|tsx)$ 32 | exclude: (generators/) # TODO 33 | - id: tslint 34 | name: TSLint 35 | language: system 36 | entry: ./node_modules/.bin/tslint 37 | args: [--fix] 38 | types: [file] 39 | files: \.(js|jsx|ts|tsx)$ 40 | exclude: (generators/) # TODO 41 | - id: prettier 42 | name: Prettier 43 | language: node 44 | entry: ./node_modules/.bin/prettier 45 | args: [--write] 46 | -------------------------------------------------------------------------------- /src/checks/matchingBelongsTo.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 3 | import { ModelMap, StringKeys, SyncError } from '@wildebeest/types'; 4 | import getAssociationsByModelName from '@wildebeest/utils/getAssociationsByModelName'; 5 | 6 | /** 7 | * For `hasOne` and `hasMany` relations, check that they have an opposing `belongsTo` association configured. 8 | * 9 | * @param wildebeest - The wildebeest config 10 | * @param modelName - The name of the model that has the `hasOne` or `hasMany` relations 11 | * @param associationTable - The name of the table being associated 12 | * @returns Any errors related to mismatching belongs to configurations 13 | */ 14 | export default function checkMatchingBelongsTo( 15 | wildebeest: Wildebeest, 16 | modelName: StringKeys, 17 | associationTable: StringKeys, 18 | ): SyncError[] { 19 | // Keep track of errors 20 | const errors: SyncError[] = []; 21 | 22 | // Get the associations of the association 23 | const { associations = {} } = wildebeest.getModelDefinition(associationTable); 24 | const { belongsTo = {} } = associations; 25 | 26 | // Ensure `modelName` is found in one of the associations 27 | if (getAssociationsByModelName(modelName, belongsTo).length === 0) { 28 | errors.push({ 29 | message: `Missing belongsTo opposite on "${associationTable}" to ${modelName}`, 30 | tableName: wildebeest.pluralCase(modelName), 31 | }); 32 | } 33 | 34 | return errors; 35 | } 36 | -------------------------------------------------------------------------------- /src/checks/allowNullConstraint.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { ModelAttributeColumnOptions } from 'sequelize'; 3 | 4 | // global 5 | import { ModelMap, SyncError } from '@wildebeest/types'; 6 | import columnAllowsNull from '@wildebeest/utils/columnAllowsNull'; 7 | 8 | // classes 9 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 10 | 11 | /** 12 | * Check that the allowNull constraint is setup properly on the column 13 | * 14 | * @param name - The name of the attribute 15 | * @param definition - The attribute definition 16 | * @returns Any errors with the allow null constraint 17 | */ 18 | export default async function checkAllowNullConstraint< 19 | TModels extends ModelMap 20 | >( 21 | db: WildebeestDb, 22 | tableName: string, 23 | name: string, 24 | definition: ModelAttributeColumnOptions, 25 | ): Promise { 26 | // The sync errors found 27 | const errors: SyncError[] = []; 28 | 29 | // Check if expected to be unique 30 | const allowsNull = definition.allowNull === true; 31 | 32 | // Check if the column allows null values exists 33 | const dbAllowsNull = await columnAllowsNull(db, tableName, name); 34 | 35 | // Ensure both align 36 | if (allowsNull !== dbAllowsNull) { 37 | errors.push({ 38 | message: dbAllowsNull 39 | ? `Missing nonNull constraint for column "${name}" in table "${tableName}"` 40 | : `Extra nonNull constraint for column "${name}" in table "${tableName}"`, 41 | tableName, 42 | }); 43 | } 44 | 45 | return errors; 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/listConstraintNames.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { QueryTypes } from 'sequelize'; 3 | 4 | // global 5 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 6 | import { MigrationTransactionOptions, ModelMap } from '@wildebeest/types'; 7 | 8 | /** 9 | * A db constraint 10 | */ 11 | export type PgConstraint = { 12 | /** The name of the constraint */ 13 | conname: string; 14 | }; 15 | 16 | /** 17 | * List constraints related to a table 18 | * 19 | * @param db - The db instance to check 20 | * @param tableName - The table to list constraints for 21 | * @returns The names of the constraints 22 | */ 23 | export default async function listConstraintNames( 24 | db: WildebeestDb, 25 | tableName: string, 26 | transactionOptions?: MigrationTransactionOptions, 27 | ): Promise { 28 | const { queryInterface } = db; 29 | // Determine the constraints 30 | const constraints: PgConstraint[] = await queryInterface.sequelize.query( 31 | `SELECT con.* 32 | FROM pg_catalog.pg_constraint con 33 | INNER JOIN pg_catalog.pg_class rel 34 | ON rel.oid = con.conrelid 35 | INNER JOIN pg_catalog.pg_namespace nsp 36 | ON nsp.oid = connamespace 37 | WHERE nsp.nspname = 'public' 38 | AND rel.relname = '${tableName}'`, 39 | { 40 | ...transactionOptions, 41 | type: QueryTypes.SELECT, 42 | }, 43 | ); 44 | return constraints.map(({ conname }) => conname); 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/renameS3File.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { S3 } from 'aws-sdk'; 3 | 4 | // global 5 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 6 | import { ModelMap } from '@wildebeest/types'; 7 | 8 | /** 9 | * Rename an s3 file 10 | * 11 | * @param Bucket - The name of the s3 bucket 12 | * @param oldKey - The old key 13 | * @param newKey - The new key 14 | * @param mimetype - The mimetype of the file 15 | * @returns The rename promise 16 | */ 17 | export default async function renameS3File( 18 | wildebeest: Wildebeest, 19 | s3: S3, 20 | Bucket: string, 21 | oldKey: string, 22 | newKey: string, 23 | mimetype?: string, 24 | isDryRun = process.env.DRY_RUN === 'true', 25 | ): Promise { 26 | // The copy parameters 27 | const copyParams = { 28 | ...(mimetype ? { ContentType: mimetype } : {}), 29 | Bucket, 30 | MetadataDirective: 'REPLACE', 31 | CopySource: `/${Bucket}/${oldKey}`, 32 | Key: newKey, 33 | }; 34 | 35 | // Copy the object 36 | if (isDryRun) { 37 | wildebeest.logger.info(`Copied: "${oldKey}" to "${newKey}"`); 38 | } else { 39 | await s3.copyObject(copyParams).promise(); 40 | } 41 | 42 | // The delete parameters 43 | const deleteParams = { 44 | Bucket, 45 | Delete: { Objects: [{ Key: oldKey }] }, 46 | }; 47 | 48 | // Delete the old object 49 | if (isDryRun) { 50 | wildebeest.logger.info(`Deleted: "${oldKey}"`); 51 | } else { 52 | await s3.deleteObjects(deleteParams).promise(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/migrationTypes/custom.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 3 | import { 4 | MigrationDefinition, 5 | MigrationTransactionOptions, 6 | ModelMap, 7 | } from '@wildebeest/types'; 8 | 9 | /** 10 | * A migration that should run on the db 11 | */ 12 | export type TransactionMigrationDefinition = { 13 | /** The up migration (there should be no loss of data) */ 14 | up: ( 15 | transactionOptions: MigrationTransactionOptions, 16 | wildebeest: Wildebeest, 17 | ) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any 18 | /** The down migration to reverse the up migration, with potential loss of data */ 19 | down: ( 20 | transactionOptions: MigrationTransactionOptions, 21 | wildebeest: Wildebeest, 22 | ) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any 23 | }; 24 | 25 | /** 26 | * A helper to wrap a custom migration in a transaction 27 | * 28 | * @param options - The custom options 29 | * @returns The custom migrator 30 | */ 31 | export default function custom( 32 | options: TransactionMigrationDefinition, 33 | ): MigrationDefinition { 34 | const { up, down } = options; 35 | return { 36 | up: async (wildebeest, withTransaction) => 37 | withTransaction((transactionOptions) => 38 | up(transactionOptions, wildebeest), 39 | ), 40 | down: async (wildebeest, withTransaction) => 41 | withTransaction((transactionOptions) => 42 | down(transactionOptions, wildebeest), 43 | ), 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/migrationTypes/renameIndex.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { 3 | MigrationDefinition, 4 | MigrationTransactionOptions, 5 | ModelMap, 6 | } from '@wildebeest/types'; 7 | 8 | /** 9 | * The options for renaming a table index 10 | */ 11 | export type RenameIndexOptions = { 12 | /** The old name of the index */ 13 | oldName: string; 14 | /** The new name of the index */ 15 | newName: string; 16 | }; 17 | 18 | /** 19 | * Rename an index 20 | * 21 | * @param options - The rename options 22 | * @param rawTransactionOptions - The existing transaction 23 | * @returns The rename table promise 24 | */ 25 | export async function renameTableIndex( 26 | { oldName, newName }: RenameIndexOptions, 27 | { queryT }: MigrationTransactionOptions, 28 | ): Promise { 29 | await queryT.raw(`ALTER INDEX "${oldName}" RENAME TO "${newName}"`); 30 | } 31 | 32 | /** 33 | * Rename an index 34 | * 35 | * @param options - Rename index options 36 | * @returns The rename index migrator 37 | */ 38 | export default function renameIndex( 39 | options: RenameIndexOptions, 40 | ): MigrationDefinition { 41 | return { 42 | up: async (_, withTransaction) => 43 | withTransaction((transactionOptions) => 44 | renameTableIndex(options, transactionOptions), 45 | ), 46 | down: async (_, withTransaction) => 47 | withTransaction((transactionOptions) => 48 | renameTableIndex( 49 | { 50 | newName: options.oldName, 51 | oldName: options.newName, 52 | }, 53 | transactionOptions, 54 | ), 55 | ), 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /generateMixins/helpers/createBase.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { writeFileSync } from 'fs'; 3 | import { join } from 'path'; 4 | 5 | // mixins 6 | import { 7 | AssociationsDefinition, 8 | BaseFileInput, 9 | GenerateMixinsConfig, 10 | } from '../types'; 11 | 12 | // local 13 | import associationDefinitionToSections from './associationDefinitionToSections'; 14 | import determineModelToExtend from './determineModelToExtend'; 15 | 16 | /** 17 | * Process file 18 | * 19 | * @param associationsDefinition - The associations defined for a db mode 20 | * @param createBaseFile - A function to construct the base model file (usually handlebars compiled template) 21 | * @param config - The config for generating mixins 22 | */ 23 | export default function createBase( 24 | associationsDefinition: AssociationsDefinition, 25 | createBaseFile: (input: BaseFileInput) => string, 26 | config: Required, 27 | ): void { 28 | // convert into input sections for the handlebars template 29 | const allAssociations = associationDefinitionToSections( 30 | associationsDefinition, 31 | config, 32 | ); 33 | 34 | // Get te file path with 35 | const splitter = associationsDefinition.filePath.split('/'); 36 | if (splitter[splitter.length - 1] === config.associationFileName) { 37 | splitter.pop(); 38 | } 39 | 40 | const compiled = createBaseFile({ 41 | ...(config.determineModelToExtend(associationsDefinition, config) || 42 | determineModelToExtend(associationsDefinition, config)), 43 | sections: allAssociations, 44 | }); 45 | 46 | writeFileSync( 47 | join(splitter.concat([config.baseFileName]).join('/')), 48 | compiled, 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Routes for running migrations 3 | */ 4 | 5 | // external 6 | import express from 'express'; 7 | 8 | // local 9 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 10 | import { ModelMap } from '@wildebeest/types'; 11 | import * as controller from './controller'; 12 | 13 | /** 14 | * Construct a router 15 | */ 16 | export default ( 17 | wildebeest: Wildebeest, 18 | ): express.Router => { 19 | // instantiate a router 20 | const router = express.Router(); 21 | 22 | // /////////// // 23 | // MIDDLEWARES // 24 | // /////////// // 25 | 26 | // Save the wildebeest onto res.locals 27 | router.all('*', (_, res, next) => { 28 | res.locals.wildebeest = wildebeest; 29 | next(); 30 | }); 31 | 32 | // /////////// // 33 | // CONTROLLERS // 34 | // /////////// // 35 | 36 | // Display the seed routes 37 | router.get('/', controller.renderRoutes); 38 | 39 | // Migrate all the way forward 40 | router.get('/current', controller.current); 41 | 42 | // Migrate all the way back 43 | router.get('/empty', controller.empty); 44 | 45 | // Check if the db is in sync with code 46 | router.get('/sync', controller.sync); 47 | 48 | // Run genesis down, then all the way up, down up, down, up 49 | router.get('/test', controller.test); 50 | 51 | // Run up to this migration 52 | router.get('/up/:num', controller.up); 53 | 54 | // Run down to this migration 55 | router.get('/down/:num', controller.down); 56 | 57 | // Write the current schema to file 58 | if (wildebeest.allowSchemaWrites) { 59 | router.get('/write-schema/:name', controller.writeSchemaByName); 60 | } 61 | return router; 62 | }; 63 | -------------------------------------------------------------------------------- /generateMixins/base.hbs: -------------------------------------------------------------------------------- 1 | /* tslint:disable completed-docs */ 2 | /** 3 | * 4 | * ## {{ pascalCase container }} Base Model 5 | * The db model definition from which this model definition is inheriting from. 6 | * 7 | * WARNING: This file is 100% generated. 8 | * 9 | * This is the base class definition with all [Sequelize mixins](https://sequelize.readthedocs.io/en/latest/api/associations/) applied. 10 | * 11 | * This class definition is an [abstract](https://www.typescriptlang.org/docs/handbook/classes.html#abstract-classes) class and may require a certain API to be properly implemented. 12 | */ 13 | 14 | // wildebeest 15 | import * as w from '@transcend-io/wildebeest'; 16 | 17 | // db 18 | import type * as M from '{{ modelDefinitionsPath }}'; 19 | {{#ifEqual modelBaseFolder "db"}} 20 | import {{ ModelBaseName }} from '@bk/{{ modelBaseFolder }}/{{ ModelBaseName }}'; 21 | {{/ifEqual}} 22 | {{#ifNotEqual modelBaseFolder "db"}} 23 | 24 | // {{ modelBaseFolder }} 25 | import {{ ModelBaseName }} from '@bk/{{ modelBaseFolder }}/{{ ModelBaseName }}'; 26 | {{/ifNotEqual}} 27 | 28 | /** 29 | * This is the base class definition with all mixins applied from associations on the {{ pascalCase container }} 30 | */ 31 | export default abstract class {{ pascalCase container }}Base extends {{ ModelBaseName }} { 32 | {{#each sections}} 33 | 34 | // {{ pad associationType }} // 35 | // {{ associationType }} // 36 | // {{ pad associationType }} // 37 | {{#each definitions}} 38 | 39 | /** Association with `{{ modelName }}`{{#ifNotEqual associationName modelName}} as `{{ associationName }}`{{/ifNotEqual}} */ 40 | {{#each attributes}} 41 | {{{ attribute }}} 42 | {{/each}} 43 | {{/each}} 44 | {{/each}} 45 | }; 46 | -------------------------------------------------------------------------------- /generators/generators/migration/index.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { paramCase } from 'change-case'; 3 | 4 | // global 5 | import migrationTypes from '@generators/migrationTypes'; 6 | 7 | const DEFAULT_TEMPLATE = (generatorName): string => `{{> dbMigrationHeader }} 8 | // migrationTypes 9 | import ${generatorName} from '@bk/migrations/migrationTypes/${generatorName}';${'\n'} 10 | ${`${'module.'}${'exports'}`} = ${generatorName}({ 11 | TODO 12 | }); 13 | `; 14 | 15 | /** 16 | * Create a new migration type 17 | * 18 | * TODO wildebeest 19 | */ 20 | export default { 21 | description: 'Create a new migration type', 22 | location: 'migrations', 23 | prompts: { 24 | generatorType: { 25 | source: () => ['', ...Object.keys(migrationTypes)], 26 | message: 'What type of migration?', 27 | type: 'autocomplete', 28 | }, 29 | templateContents: { 30 | default: ({ container }) => DEFAULT_TEMPLATE(container), 31 | message: 'What is the migration template?', 32 | extension: 'hbs', 33 | type: 'editor', 34 | }, 35 | nameExt: { 36 | message: ({ container }) => 37 | `What should be appended to "${paramCase( 38 | container, 39 | )}" to create the name of the migration?`, 40 | type: 'input', 41 | required: true, 42 | }, 43 | defaultComment: { 44 | message: 'What should be the default comment for the migration?', 45 | type: 'input', 46 | required: true, 47 | }, 48 | }, 49 | requiredFiles: [ 50 | { 51 | generator: 'handlebars-template', 52 | config: () => ({ name: 'template', extension: 'hbs' }), 53 | }, 54 | ], 55 | skipActions: true, 56 | type: 'generatorContainer', 57 | }; 58 | -------------------------------------------------------------------------------- /src/checks/extraneousTables.ts: -------------------------------------------------------------------------------- 1 | // external moduels 2 | import difference from 'lodash/difference'; 3 | import { QueryTypes } from 'sequelize'; 4 | 5 | // global 6 | import { ModelMap, SyncError } from '@wildebeest/types'; 7 | 8 | // classes 9 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 10 | 11 | /** 12 | * When querying a table 13 | */ 14 | export type TableDefinition = { 15 | /** The postgres schema the table belongs to */ 16 | schemaname: string; 17 | /** The name of the table */ 18 | tablename: string; 19 | }; 20 | 21 | /** 22 | * Ensure the default value of a sequelize definition matches the default value in postgres 23 | * 24 | * @param wildebeest - The wildebeest db to operate on 25 | * @param tableNames - The tables names that should exist 26 | * @returns Any errors related to extra tables. 27 | */ 28 | export default async function checkExtraneousTables( 29 | db: WildebeestDb, 30 | tableNames: string[], 31 | ): Promise { 32 | // Keep track of errors 33 | const errors: SyncError[] = []; 34 | 35 | // Check for existing tables tables 36 | const tables: TableDefinition[] = await db.query( 37 | 'SELECT schemaname, tablename FROM pg_catalog.pg_tables where "schemaname"=\'public\';', 38 | { type: QueryTypes.SELECT }, 39 | ); 40 | 41 | // Determine if there are any extra 42 | const extraTables = difference( 43 | tables.map(({ tablename }) => tablename), 44 | tableNames, 45 | ); 46 | if (extraTables.length > 0) { 47 | extraTables.map((tableName) => 48 | errors.push({ 49 | message: `\nExtra table definitions: "${extraTables.join('", "')}"`, 50 | tableName, 51 | }), 52 | ); 53 | } 54 | 55 | return errors; 56 | } 57 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import mkEnum from '@wildebeest/utils/mkEnum'; 3 | 4 | /** 5 | * A type of association 6 | */ 7 | export enum AssociationType { 8 | /** Belongs to add id to col */ 9 | BelongsTo = 'belongsTo', 10 | /** Join */ 11 | BelongsToMany = 'belongsToMany', 12 | /** Has many */ 13 | HasMany = 'hasMany', 14 | /** Has one */ 15 | HasOne = 'hasOne', 16 | } 17 | 18 | /** 19 | * The supported postgres index types 20 | */ 21 | export enum IndexType { 22 | /** An index on a primary key */ 23 | PrimaryKey = 'primary key', 24 | /** A column or set of columns that are unique */ 25 | Unique = 'unique', 26 | /** (default) A regular database index */ 27 | Index = 'index', 28 | } 29 | 30 | /** 31 | * The supported postgres index methods 32 | */ 33 | export enum IndexMethod { 34 | /** A binary tree index */ 35 | BTree = 'BTREE', 36 | /** A hash index */ 37 | Hash = 'HASH', 38 | /** Gist index */ 39 | Gist = 'GIST', 40 | /** Gin index */ 41 | Gin = 'GIN', 42 | /** Sp gist index */ 43 | SPGist = 'SPGIST', 44 | /** Brin index */ 45 | Brin = 'BRIN', 46 | } 47 | 48 | /** 49 | * The names of the db models can be overwritten 50 | */ 51 | export const DefaultTableNames = mkEnum({ 52 | migrationLock: 'migrationLocks', 53 | migration: 'migrations', 54 | }); 55 | 56 | /** 57 | * Possible options on association model delete 58 | */ 59 | export const OnDelete = mkEnum({ 60 | /** Cascade the deletion to this table */ 61 | Cascade: 'CASCADE', 62 | /** Set the column to null */ 63 | SetNull: 'SET NULL', 64 | /** Do nothing (often not used) */ 65 | NoAction: 'NO ACTION', 66 | }); 67 | 68 | /** 69 | * Overload with type of on delete 70 | */ 71 | export type OnDelete = typeof OnDelete[keyof typeof OnDelete]; 72 | -------------------------------------------------------------------------------- /generators/index.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import difference from 'lodash/difference'; 3 | import kebabCase from 'lodash/kebabCase'; 4 | 5 | // global 6 | import logger from '@generators/logger'; 7 | import convertToPlop from '@generators/utils/convertToPlop'; 8 | 9 | /** 10 | * Create a db migration 11 | */ 12 | export default { 13 | description: 'Run a db migration', 14 | getGenerators: ( 15 | { actions, cwdFilter, description, generatorName, location, prompts }, 16 | { count, cwdLocation, name, options }, 17 | repo, 18 | ) => { 19 | // The refactor generator 20 | const generators = [ 21 | { 22 | generatorName, 23 | name, 24 | count, 25 | init: convertToPlop( 26 | { 27 | actions, 28 | cwdFilter, 29 | cwdLocation, 30 | description, 31 | location, 32 | options, 33 | prompts, 34 | }, 35 | repo, 36 | ), 37 | }, 38 | ]; 39 | 40 | return generators; 41 | }, 42 | // Check that all migrationTypes have a corresponding generator 43 | postSetup: (generators, repo) => { 44 | // The migrationTypes that have been defined 45 | const migrationTypes = repo 46 | .listEntryFiles('migrations/migrationTypes') 47 | .map((fil) => kebabCase(fil.split('.')[0])) 48 | .filter((fil) => !['index', 'skip'].includes(fil)); 49 | 50 | // The existing generators 51 | const migrationTypesGenerators = generators.map(({ name }) => name); 52 | 53 | // Missing generators 54 | const missing = difference(migrationTypes, migrationTypesGenerators); 55 | if (missing.length) { 56 | logger.error(`Missing migration generators for: "${missing.join(', ')}"`); 57 | } 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /src/migrationTypes/addIndex.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { MigrationDefinition, ModelMap } from '@wildebeest/types'; 3 | import createIndex from '@wildebeest/utils/createIndex'; 4 | 5 | /** 6 | * Options for adding a new index 7 | */ 8 | export type AddIndexOptions = { 9 | /** The name of the table to add the constraint to */ 10 | tableName: string; 11 | /** The name of the columns to make unique across */ 12 | fields: string[]; 13 | /** Override the constraint name (leave null for default) */ 14 | constraintName?: string; 15 | }; 16 | 17 | /** 18 | * Create a regular index on a column or set of columns in a table 19 | * 20 | * @param options - Options for making a unique constraint across multiple columns 21 | * @returns The add index migrator 22 | */ 23 | export default function addIndex( 24 | options: AddIndexOptions, 25 | ): MigrationDefinition { 26 | const { tableName, fields, constraintName } = options; 27 | return { 28 | // Add the unique index 29 | up: async ({ db, namingConventions }, withTransaction) => 30 | withTransaction((transactionOptions) => 31 | createIndex( 32 | db, 33 | constraintName || 34 | namingConventions.fieldsConstraint(tableName, fields), 35 | { 36 | tableName, 37 | fields, 38 | }, 39 | transactionOptions, 40 | ), 41 | ), 42 | // Remove the index 43 | down: async ({ db, namingConventions }, withTransaction) => 44 | withTransaction((transactionOptions) => 45 | db.queryInterface.removeIndex( 46 | tableName, 47 | constraintName || 48 | namingConventions.fieldsConstraint(tableName, fields), 49 | transactionOptions, 50 | ), 51 | ), 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/migrationTypes/renameEnum.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { 3 | MigrationDefinition, 4 | MigrationTransactionOptions, 5 | ModelMap, 6 | } from '@wildebeest/types'; 7 | 8 | /** 9 | * The options for changing the name of an enum 10 | */ 11 | export type RenameEnumOptions = { 12 | /** The old name of the enum */ 13 | oldName: string; 14 | /** The new name of the enum */ 15 | newName: string; 16 | }; 17 | 18 | /** 19 | * Change the name of an enum 20 | * 21 | * @param options - The change enum options 22 | * @param rawTransactionOptions - The raw transaction options 23 | * @returns The change name promise 24 | */ 25 | export async function changeEnumName( 26 | options: RenameEnumOptions, 27 | transactionOptions: MigrationTransactionOptions, 28 | ): Promise { 29 | // Raw query interface 30 | const { queryT } = transactionOptions; 31 | 32 | // Change the name 33 | await queryT.raw( 34 | `ALTER TYPE "${options.oldName}" RENAME TO "${options.newName}"`, 35 | ); 36 | } 37 | 38 | /** 39 | * Rename an enum definition 40 | * 41 | * @param options - The change enum options 42 | * @returns The rename enum migrator 43 | */ 44 | export default function renameEnum( 45 | options: RenameEnumOptions, 46 | ): MigrationDefinition { 47 | const { oldName, newName } = options; 48 | return { 49 | up: async (_, withTransaction) => 50 | withTransaction((transactionOptions) => 51 | changeEnumName({ oldName, newName }, transactionOptions), 52 | ), 53 | down: async (_, withTransaction) => 54 | withTransaction((transactionOptions) => 55 | changeEnumName( 56 | { newName: oldName, oldName: newName }, 57 | transactionOptions, 58 | ), 59 | ), 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /generateMixins/index.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { readFileSync } from 'fs'; 3 | 4 | // local 5 | import Handlebars from './Handlebars'; 6 | import { 7 | createBase, 8 | extractAssociationDefinitions, 9 | getCompilerOptions, 10 | setConfigDefaults, 11 | } from './helpers'; 12 | import { BaseFileInput, GenerateMixinsConfig } from './types'; 13 | 14 | /** 15 | * Main runner for mixin generation 16 | * 17 | * @param configPath - The path to the config specified via command line arg 18 | */ 19 | export default function generateMixins( 20 | mixinConfig: GenerateMixinsConfig, 21 | ): void { 22 | // Read in the config and set defaults 23 | const config = setConfigDefaults(mixinConfig); 24 | 25 | // Register any custom handlebars helpers 26 | Object.entries(config.handlebarsHelpers).map(([name, func]) => 27 | Handlebars.registerHelper(name, func), 28 | ); 29 | 30 | // Compile the handlebars template 31 | const TEMPLATE = Handlebars.compile( 32 | readFileSync(config.baseModelTemplatePath, 'utf-8'), 33 | ); 34 | 35 | // Get the typescript compiler options 36 | const compilerOptions = getCompilerOptions( 37 | config.rootPath, 38 | config.tsConfigPath, 39 | ); 40 | 41 | // Extract the association definitions for each model 42 | const associationFiles = extractAssociationDefinitions( 43 | [`${config.rootPath}/${config.src}`], 44 | { 45 | ...compilerOptions, 46 | moduleResolution: undefined, 47 | }, 48 | config, 49 | ); 50 | 51 | // Process each definition and generate the Base model file 52 | associationFiles.forEach((associationDefinition) => 53 | createBase(associationDefinition, TEMPLATE, config), 54 | ); 55 | 56 | // print out the doc 57 | // writeFileSync('types.json', JSON.stringify(output, undefined, 4)); 58 | } 59 | -------------------------------------------------------------------------------- /src/Logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console,class-methods-use-this */ 2 | // external 3 | /* tslint:disable no-var-requires */ 4 | const chalk = require('chalk'); // eslint-disable-line @typescript-eslint/no-var-requires 5 | /* tslint:enable no-var-requires */ 6 | 7 | const applyChalkColor = ( 8 | color: 'red' | 'green' | 'blue' | 'bold' | 'yellow', 9 | props: Parameters, 10 | ): Parameters => 11 | props.map((p) => (typeof p === 'string' ? chalk[color](p) : p)) as Parameters< 12 | typeof console.log 13 | >; 14 | 15 | /** 16 | * The logger can be overwritten with custom logging interfaces, however we require the logger to implement the following 17 | */ 18 | export default class Logger { 19 | /** 20 | * Standard console.log 21 | */ 22 | public log(...props: Parameters): void { 23 | console.log(...props); 24 | } 25 | 26 | /** 27 | * Standard console.error 28 | */ 29 | public error(...props: Parameters): void { 30 | console.error(...applyChalkColor('red', props)); 31 | } 32 | 33 | /** 34 | * Standard console.warn 35 | */ 36 | public warn(...props: Parameters): void { 37 | console.warn(...applyChalkColor('yellow', props)); 38 | } 39 | 40 | /** 41 | * Standard console.info 42 | */ 43 | public info(...props: Parameters): void { 44 | console.info(...applyChalkColor('blue', props)); 45 | } 46 | 47 | /** 48 | * Standard console.info 49 | */ 50 | public debug(...props: Parameters): void { 51 | console.info(...applyChalkColor('green', props)); 52 | } 53 | 54 | /** 55 | * Print a divider 56 | */ 57 | public divide(): void { 58 | this.info('-----------------------------------'); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /generateMixins/helpers/associationDefinitionToSections.ts: -------------------------------------------------------------------------------- 1 | // mixins 2 | import { 3 | AssociationsDefinition, 4 | AssociationSection, 5 | GenerateMixinsConfig, 6 | } from '../types'; 7 | 8 | // local 9 | import calculateMixinAttributes from './calculateMixinAttributes'; 10 | import getKeys from './getKeys'; 11 | 12 | /** 13 | * Convert an associations file definition to a list of the association type sections with their accompanied 14 | * 15 | * @param associationsDefinition - The section to process 16 | * @param config - The mixin generation config 17 | * @returns The association definitions 18 | */ 19 | export default function associationDefinitionToSections( 20 | { associations }: AssociationsDefinition, 21 | config: GenerateMixinsConfig, 22 | ): AssociationSection[] { 23 | return ( 24 | getKeys(associations) 25 | // Remove any associations that are empty objects 26 | .filter( 27 | (associationType) => 28 | Object.values(associations[associationType]).length > 0, 29 | ) 30 | // Construct the section config 31 | .map((associationType) => ({ 32 | associationType, 33 | // Determine the association definitions 34 | definitions: getKeys(associations[associationType]).map( 35 | (associationName) => ({ 36 | associationName, 37 | ...associations[associationType][associationName], 38 | // Determine the mixin attributes to inject 39 | attributes: calculateMixinAttributes( 40 | { 41 | associationName, 42 | ...associations[associationType][associationName], 43 | }, 44 | associationType, 45 | config.getCustomMixinAttributes, 46 | config.pluralCase, 47 | ), 48 | }), 49 | ), 50 | })) 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /generateMixins/helpers/calculateMixinAttributes.ts: -------------------------------------------------------------------------------- 1 | // mixins 2 | import ChangeCase from '../ChangeCase'; 3 | import { SEQUELIZE_MIXINS, WILDEBEEST_MIXINS } from '../constants'; 4 | import { 5 | AssociationInput, 6 | AssociationMixins, 7 | AssociationType, 8 | MixinAttributeInput, 9 | MixinAttributes, 10 | } from '../types'; 11 | 12 | /** 13 | * Get the typescript configuration in the directory 14 | * 15 | * @param association - The association definition to calculate the attributes for 16 | * @param associationType - The type of association to get the attributes for 17 | * @param additionMixins - Custom mixin attributes to inject 18 | * @param pluralCase - Optionally provide a custom pluralization function 19 | * @returns The tsconfig or throws an error 20 | */ 21 | export default function calculateMixinAttributes( 22 | association: Omit, 23 | associationType: AssociationType, 24 | additionMixins: Partial = {}, 25 | pluralCase?: (word: string) => string, 26 | ): MixinAttributes[] { 27 | const attributeInput: MixinAttributeInput = { 28 | modelName: new ChangeCase(association.modelName, pluralCase), 29 | associationName: new ChangeCase(association.associationName, pluralCase), 30 | primaryKeyName: association.primaryKeyName, 31 | throughModelName: association.throughModelName, 32 | }; 33 | 34 | // Check if there are custom mixins 35 | const customMixins = additionMixins[associationType]; 36 | 37 | // Calculate the mixins to inject 38 | const sequelizeMixins = SEQUELIZE_MIXINS[associationType](attributeInput); 39 | const wildebeestMixins = WILDEBEEST_MIXINS[associationType](attributeInput); 40 | const additionalMixins = customMixins ? customMixins(attributeInput) : []; 41 | 42 | // Merge them all together 43 | return [...sequelizeMixins, ...wildebeestMixins, ...additionalMixins]; 44 | } 45 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "jsEnable": false, 4 | "rulesDirectory": ["node_modules/tslint-microsoft-contrib"], 5 | "esSpecCompliant": true, 6 | "rules": { 7 | "array-type": false, 8 | "camel-case": false, 9 | "completed-docs": [ 10 | true, 11 | "classes", 12 | "enums", 13 | "enum-members", 14 | "functions", 15 | "interfaces", 16 | "methods", 17 | "namespaces", 18 | "properties", 19 | "types" 20 | ], 21 | "export-name": false, 22 | "interface-over-type-literal": false, 23 | "max-line-length": false, 24 | "new-parens": true, 25 | "prefer-for-of": false, 26 | "no-arg": true, 27 | "ordered-imports": true, 28 | "object-literal-key-quotes": false, 29 | "no-bitwise": true, 30 | "no-conditional-assignment": true, 31 | "no-consecutive-blank-lines": false, 32 | "no-console": false, 33 | "no-unused-expression": false, 34 | "no-namespace": false, 35 | "no-shadowed-variable": false, 36 | "no-var-requires": false, 37 | "object-literal-sort-keys": false, 38 | "quotemark": [false, "single", "avoid-escape", "avoid-template"], 39 | "variable-name": false, 40 | "trailing-comma": false, 41 | "semicolon": false 42 | }, 43 | "jsRules": { 44 | "array-type": false, 45 | "align": false, 46 | "camel-case": false, 47 | "completed-docs": false, 48 | "max-line-length": false, 49 | "quotemark": [false, "single", "avoid-escape", "avoid-template"], 50 | "no-console": false, 51 | "object-literal-sort-keys": false, 52 | "no-shadowed-variable": false, 53 | "no-var-requires": false, 54 | "variable-name": false, 55 | "trailing-comma": false, 56 | "semicolon": false 57 | }, 58 | "linterOptions": { 59 | "exclude": [ 60 | "node_modules/**/*.ts", 61 | "dist/**/*.ts", 62 | "build/**/*.js", 63 | "builds/**/*.js" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # https://docs.travis-ci.com/user/languages/javascript-with-nodejs/ 2 | language: node_js 3 | 4 | # Make sure that branch and PR triggers are enabled in Travis CI settings on the web UI, and then 5 | # it will run whenever a PR is opened and whenever a new commit is merged into one of these branches 6 | branches: 7 | only: 8 | - master 9 | 10 | git: 11 | depth: 2 12 | 13 | # Node version 14 | node_js: '12.14.0' 15 | 16 | before_install: 17 | - echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > "$HOME/.npmrc" 18 | - npm install -g yarn@1.22.4 19 | - python -m pip install --upgrade pip --user 20 | - pip install pre-commit --user 21 | - export PATH=$PATH:$HOME/.local/bin 22 | 23 | # Install top level dependencies 24 | install: 25 | - yarn 26 | 27 | # the following line is needed to enable the TravisCI build conditions 28 | conditions: v1 29 | 30 | # Run these commands 31 | jobs: 32 | include: 33 | # ##### # 34 | # Tests # 35 | # ##### # 36 | 37 | # Run pre-commit hooks on all files 38 | - stage: test 39 | name: Pre-Commits 40 | script: 41 | - pre-commit run --all-files 42 | 43 | # Run tests 44 | - stage: test 45 | name: Tests 46 | script: npm run test 47 | 48 | # ###### # 49 | # Deploy # 50 | # ###### # 51 | 52 | # Deploy to npm 53 | - stage: deploy 54 | script: skip 55 | deploy: 56 | - provider: npm 57 | email: '$NPM_EMAIL' 58 | api_key: '$NPM_TOKEN' 59 | skip_cleanup: true 60 | on: 61 | branch: master 62 | 63 | cache: 64 | directories: 65 | # Cache pip activities to speed up pre-commit installation 66 | - $HOME/.cache/pip 67 | - $HOME/.cache/pre-commit 68 | before_cache: 69 | - rm -f $HOME/.cache/pip/log/debug.log 70 | - rm -f $HOME/.cache/pre-commit/pre-commit.log 71 | - rm -f $HOME/.npm/anonymous-cli-metrics.json 72 | 73 | addons: 74 | apt: 75 | packages: 76 | - 'python' 77 | -------------------------------------------------------------------------------- /src/checks/model.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { 3 | ConfiguredModelDefinition, 4 | ModelMap, 5 | StringKeys, 6 | SyncError, 7 | } from '@wildebeest/types'; 8 | import tableExists from '@wildebeest/utils/tableExists'; 9 | 10 | // classes 11 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 12 | 13 | // local 14 | import checkAssociationsSync from './associations'; 15 | import checkColumnDefinitions from './columnDefinitions'; 16 | import checkIndexes from './indexes'; 17 | 18 | /** 19 | * Check that a db model definition in Sequelize is in sync with the actual database definition 20 | * 21 | * @param wildebeest - The wildebeest configuration 22 | * @param model - The database model definition to verify 23 | * @param modelName - The name of the model 24 | * @returns Any errors related to the model definition 25 | */ 26 | export default async function checkModel( 27 | wildebeest: Wildebeest, 28 | model: ConfiguredModelDefinition>, 29 | modelName: StringKeys, 30 | ): Promise { 31 | // Keep track of errors 32 | const errors: SyncError[] = []; 33 | 34 | // You can skip the check 35 | if (model.skip) { 36 | return errors; 37 | } 38 | 39 | // Ensure the table exist 40 | const exists = await tableExists(wildebeest.db, model.tableName); 41 | if (!exists) { 42 | errors.push({ 43 | message: `Missing table: ${model.tableName}`, 44 | tableName: model.tableName, 45 | }); 46 | } 47 | 48 | // Additional checks 49 | const allErrors = await Promise.all([ 50 | // Check column definitions 51 | checkColumnDefinitions(wildebeest, model), 52 | // Ensure the table has the proper multi column indexes 53 | checkIndexes(wildebeest, model), 54 | // Ensure the associations are in sync 55 | checkAssociationsSync(wildebeest, model, modelName), 56 | ]); 57 | 58 | // If true, the model definition is in sync 59 | return errors.concat(...allErrors); 60 | } 61 | -------------------------------------------------------------------------------- /src/migrationTypes/makeColumnUnique.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { MigrationDefinition, ModelMap } from '@wildebeest/types'; 3 | 4 | /** 5 | * The type of constraint 6 | */ 7 | export type ConstraintType = 'unique'; 8 | 9 | /** 10 | * Options for making a column unique 11 | */ 12 | export type MakeColumnUniqueOptions = { 13 | /** The name of the table to change the column on */ 14 | tableName: string; 15 | /** The name of the column to make unique */ 16 | columnName: string; 17 | /** Override the default constraint name */ 18 | constraintName?: string; 19 | /** The type of constraint to make */ 20 | constraintType?: ConstraintType; 21 | }; 22 | 23 | /** 24 | * Make a column unique 25 | * 26 | * @param options - Options for making the table column unique 27 | * @returns The make column unique migrator 28 | */ 29 | export default function makeColumnUnique( 30 | options: MakeColumnUniqueOptions, 31 | ): MigrationDefinition { 32 | const { 33 | tableName, 34 | columnName, 35 | constraintName, 36 | constraintType = 'unique', 37 | } = options; 38 | return { 39 | // Add the constraint 40 | up: async ({ db, namingConventions }, withTransaction) => 41 | withTransaction((transactionOptions) => 42 | db.queryInterface.addConstraint(tableName, [columnName], { 43 | type: constraintType, 44 | name: 45 | constraintName || 46 | namingConventions.uniqueConstraint(tableName, columnName), 47 | ...transactionOptions, 48 | }), 49 | ), 50 | // Remove the constraint 51 | down: async ({ db, namingConventions }, withTransaction) => 52 | withTransaction((transactionOptions) => 53 | db.queryInterface.sequelize.query( 54 | `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${ 55 | constraintName || 56 | namingConventions.uniqueConstraint(tableName, columnName) 57 | }";`, 58 | transactionOptions, 59 | ), 60 | ), 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/classes/WildebeestDb.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A modified sequelize database class with helper functions useful to wildebeest 3 | */ 4 | 5 | // external 6 | import { 7 | DataTypes, 8 | Options, 9 | QueryInterface, 10 | Sequelize, 11 | Transaction, 12 | } from 'sequelize'; 13 | 14 | // global 15 | import { ModelMap, StringKeys } from '@wildebeest/types'; 16 | 17 | /** 18 | * A db model 19 | */ 20 | export default class WildebeestDb extends Sequelize { 21 | /** No need to call getter to access queryInterface during migrations */ 22 | public queryInterface: QueryInterface; 23 | 24 | /** The data types */ 25 | public DataTypes = DataTypes; 26 | 27 | /** Db model needs to be fixed */ 28 | public model!: >( 29 | modelName: TModelName, 30 | ) => TModels[TModelName]; 31 | 32 | /** The uri of the database */ 33 | public databaseUri: string; 34 | 35 | /** 36 | * Connect a global query interface 37 | * 38 | * @param databaseUri - The URI of the db 39 | * @param options - Additional options 40 | */ 41 | public constructor(databaseUri: string, options?: Options) { 42 | super(databaseUri, options); 43 | this.databaseUri = databaseUri; 44 | this.queryInterface = this.getQueryInterface(); 45 | } 46 | 47 | /** 48 | * Helper to continue transaction or create a new 49 | * 50 | * @param options - The transaction options 51 | * @param callback - The callback to execute in a transaction 52 | * @returns The callback result 53 | */ 54 | public continueTransaction< 55 | TOptions extends 56 | | { 57 | /** The current transaction */ 58 | transaction?: Transaction; 59 | } 60 | | undefined, 61 | T 62 | >( 63 | options: TOptions, 64 | callback: (t: Transaction) => PromiseLike, 65 | ): Promise { 66 | if (options && options.transaction) { 67 | return Promise.resolve(callback(options.transaction)); 68 | } 69 | return this.transaction((t) => callback(t)); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /generators/migrationTypes/changeEnumAttributes/index.ts: -------------------------------------------------------------------------------- 1 | // local 2 | import { listEnumAttributes } from './lib'; 3 | 4 | // migrationTypes 5 | import { SEQUELIZE_ENUM_NAME_REGEX } from '@generators//migrationTypes/tableColumnsMigration/lib'; 6 | import { listAllAttributeConfigs } from '../tableColumnMigration/lib'; 7 | 8 | let columnName; 9 | let useModelPath; 10 | 11 | /** 12 | * A higher order generator that creates a Change enum attributes migrator. 13 | * 14 | * Changing attributes of an enum 15 | * 16 | */ 17 | export default { 18 | parentType: 'alterEnum', 19 | prompts: { 20 | attributes: (repo, { attributesSuggestOnly = false }) => ({ 21 | message: 'Choose another attribute?', 22 | // Remove auto items when auto change on 23 | source: ({ enumName, modelPath }) => { 24 | // The name of the column 25 | if (!columnName) { 26 | columnName = enumName.split('_').pop(); 27 | } 28 | if (!useModelPath) { 29 | useModelPath = modelPath; 30 | } 31 | 32 | // Get the attribute config 33 | const [ 34 | { 35 | config: { content }, 36 | relativePath, 37 | }, 38 | ] = listAllAttributeConfigs(repo, useModelPath).filter( 39 | (attribute) => attribute.columnName === columnName, 40 | ); 41 | 42 | // Determine the name of the enum 43 | if (!SEQUELIZE_ENUM_NAME_REGEX.test(content)) { 44 | throw new Error(`Unknown enum definition "${enumName}"`); 45 | } 46 | const [, enumInstanceName] = SEQUELIZE_ENUM_NAME_REGEX.exec(content); 47 | 48 | // Extract the export config of the enum 49 | const enumConfig = repo.getConfigForImport( 50 | relativePath, 51 | enumInstanceName, 52 | ); 53 | 54 | // List the enum attributes 55 | return listEnumAttributes(enumConfig.content); 56 | }, 57 | itemName: 'name', 58 | suggestOnly: attributesSuggestOnly, 59 | type: 'recurseWithoutRepeat', 60 | }), 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /fix_absolute_imports.js: -------------------------------------------------------------------------------- 1 | // external 2 | const { join } = require('path'); 3 | const { readFileSync, writeFileSync } = require('fs'); 4 | const glob = require('glob'); 5 | 6 | // TODO take form checks 7 | const ROOTS = [__dirname]; 8 | 9 | /** 10 | * Remove JSON Comments from tsconfig 11 | */ 12 | function removeJSONComments(jsonString) { 13 | return jsonString.replace(/\/\/.*?\n|\/\* .*?\n/g, ''); 14 | } 15 | 16 | /** 17 | * Run fix 18 | */ 19 | function fix(fil, dist, compilerOptions) { 20 | // Read in the file contents 21 | let fileData = readFileSync(fil, 'utf-8'); 22 | 23 | // Determine the relative path of the file to the build base 24 | const diff = fil.replace(`${dist}/`, ''); 25 | 26 | // Determine relative pass to build 27 | const relativeToBuild = '../'.repeat(diff.split('/').length - 1) || './'; 28 | 29 | Object.entries(compilerOptions.paths).forEach(([key, [rep]]) => { 30 | // The require statements to replace 31 | const keyPrefix = key.split('/*')[0]; 32 | 33 | // Create a regex to find all matches 34 | const MATCH_REGEX = new RegExp(`${keyPrefix}/`, 'g'); 35 | 36 | // Determine what to replace 37 | let path = rep.split('/*')[0]; 38 | if (path.startsWith('node_modules/')) { 39 | path = path.replace('node_modules/', ''); 40 | if (!path.endsWith('/')) { 41 | path += '/'; 42 | } 43 | } else if (path.includes('build')) { 44 | path = `${relativeToBuild}${path}/`; 45 | } else { 46 | path = relativeToBuild; 47 | } 48 | 49 | fileData = fileData.replace(MATCH_REGEX, path); 50 | }); 51 | 52 | writeFileSync(fil, fileData, 'utf-8'); 53 | } 54 | 55 | /** 56 | * Convert build 57 | */ 58 | function convertBuild(rootPath) { 59 | const { compilerOptions } = JSON.parse( 60 | removeJSONComments(readFileSync(join(rootPath, 'tsconfig.json'), 'utf-8')), 61 | ); 62 | const dist = join(rootPath, compilerOptions.outDir, 'src'); 63 | const files = glob.sync(`${dist}/**/*.js`); 64 | const dTsFiles = glob.sync(`${dist}/**/*.d.ts`); 65 | [...files, ...dTsFiles].map((fil) => fix(fil, dist, compilerOptions)); 66 | } 67 | 68 | ROOTS.map(convertBuild); 69 | -------------------------------------------------------------------------------- /src/migrationTypes/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions that will create a specific type of database migration. 3 | */ 4 | 5 | // local 6 | export { default as addCascadeWithParent } from './addCascadeWithParent'; 7 | export { default as addColumns } from './addColumns'; 8 | export { default as addEnumValues } from './addEnumValues'; 9 | export { default as addIndex } from './addIndex'; 10 | export { default as addUniqueConstraintIndex } from './addUniqueConstraintIndex'; 11 | export { default as cascadeWithParent } from './cascadeWithParent'; 12 | export { default as changeColumn } from './changeColumn'; 13 | export { default as changeColumnDefault } from './changeColumnDefault'; 14 | export { default as changeEnumColumn } from './changeEnumColumn'; 15 | export { default as changeOnDelete } from './changeOnDelete'; 16 | export { default as custom } from './custom'; 17 | export { default as convertEnumValues } from './convertEnumValues'; 18 | export { default as createTable } from './createTable'; 19 | export { default as dropTable } from './dropTable'; 20 | export { default as init } from './init'; 21 | export { default as makeColumnAllowNull } from './makeColumnAllowNull'; 22 | export { default as makeColumnNonNull } from './makeColumnNonNull'; 23 | export { default as makeColumnNotUnique } from './makeColumnNotUnique'; 24 | export { default as makeColumnUnique } from './makeColumnUnique'; 25 | export { default as removeColumns } from './removeColumns'; 26 | export { default as removeEnumValues } from './removeEnumValues'; 27 | export { default as removeIndex } from './removeIndex'; 28 | export { default as removeNonNullColumn } from './removeNonNullColumn'; 29 | export { default as removeUniqueConstraintIndex } from './removeUniqueConstraintIndex'; 30 | export { default as renameColumn } from './renameColumn'; 31 | export { default as renameConstraint } from './renameConstraint'; 32 | export { default as renameEnum } from './renameEnum'; 33 | export { default as renameEnumValue } from './renameEnumValue'; 34 | export { default as renameIndex } from './renameIndex'; 35 | export { default as renameS3Files } from './renameS3Files'; 36 | export { default as renameTable } from './renameTable'; 37 | export { default as skip } from './skip'; 38 | export { default as uuidNonNull } from './uuidNonNull'; 39 | -------------------------------------------------------------------------------- /generators/migrations/index.ts: -------------------------------------------------------------------------------- 1 | // local 2 | import addCascadeWithParent from './addCascadeWithParent'; 3 | import addColumns from './addColumns'; 4 | import addEnumValues from './addEnumValues'; 5 | import addIndex from './addIndex'; 6 | import addUniqueConstraintIndex from './addUniqueConstraintIndex'; 7 | import bulkDelete from './bulkDelete'; 8 | import bulkInsert from './bulkInsert'; 9 | import cascadeWithParent from './cascadeWithParent'; 10 | import changeColumn from './changeColumn'; 11 | import changeEnumColumn from './changeEnumColumn'; 12 | import changeOnDelete from './changeOnDelete'; 13 | import createTable from './createTable'; 14 | import custom from './custom'; 15 | import dropTable from './dropTable'; 16 | import makeColumnAllowNull from './makeColumnAllowNull'; 17 | import makeColumnNonNull from './makeColumnNonNull'; 18 | import makeColumnUnique from './makeColumnUnique'; 19 | import removeColumns from './removeColumns'; 20 | import removeEnumValues from './removeEnumValues'; 21 | import removeIndex from './removeIndex'; 22 | import removeNonNullColumn from './removeNonNullColumn'; 23 | import removeUniqueConstraintIndex from './removeUniqueConstraintIndex'; 24 | import renameEnumValue from './rename-enum-value'; 25 | import renameColumn from './renameColumn'; 26 | import renameConstraint from './renameConstraint'; 27 | import renameEnum from './renameEnum'; 28 | import renameIndex from './renameIndex'; 29 | import renameS3Files from './renameS3Files'; 30 | import renameTable from './renameTable'; 31 | import uuidNonNull from './uuidNonNull'; 32 | 33 | /** 34 | * Migration generators 35 | */ 36 | export default { 37 | addCascadeWithParent, 38 | addColumns, 39 | addEnumValues, 40 | addIndex, 41 | addUniqueConstraintIndex, 42 | bulkDelete, 43 | bulkInsert, 44 | cascadeWithParent, 45 | changeColumn, 46 | changeEnumColumn, 47 | changeOnDelete, 48 | createTable, 49 | custom, 50 | dropTable, 51 | makeColumnAllowNull, 52 | makeColumnNonNull, 53 | makeColumnUnique, 54 | removeColumns, 55 | removeEnumValues, 56 | removeIndex, 57 | removeNonNullColumn, 58 | removeUniqueConstraintIndex, 59 | renameColumn, 60 | renameConstraint, 61 | renameEnum, 62 | renameEnumValue, 63 | renameIndex, 64 | renameS3Files, 65 | renameTable, 66 | uuidNonNull, 67 | }; 68 | -------------------------------------------------------------------------------- /generators/migrations/renameS3Files/index.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { NOT_EMPTY_REGEX } from '@generators/regexes'; 3 | 4 | const getAttributes = (attributes): string[] => 5 | attributes 6 | .split(',') 7 | .map((attr) => attr.trim()) 8 | .map((attr) => 9 | attr.startsWith('"') && attr.endsWith('') 10 | ? attr.slice(1, attr.length - 1) 11 | : attr, 12 | ); 13 | 14 | /** 15 | * Change the s3 file naming convention for a table 16 | */ 17 | 18 | export default { 19 | configure: ({ modelTableName, nameExt, model }) => ({ 20 | name: `rename-s3-files-${modelTableName}-${nameExt}`, 21 | comment: `Change the naming convention of s3 files in ${model}`, 22 | }), 23 | description: 'Change the s3 file naming convention for a table', 24 | prompts: { 25 | nameExt: { 26 | message: 27 | 'Name the migration (will be appended after "rename-s3-files-{{ modelTableName }}-"', 28 | type: 'name', 29 | }, 30 | bucket: { 31 | message: "What is the bucket? AWS_BUCKET['??']", 32 | type: 'input', 33 | validate: (value) => 34 | NOT_EMPTY_REGEX.test(value) ? true : 'bucket is required', 35 | }, 36 | attributes: { 37 | default: 'id', 38 | message: 39 | 'What attributes should be fetched for each row of the table, used to determine its old and new keys?', 40 | type: 'input', 41 | validate: (value) => 42 | NOT_EMPTY_REGEX.test(value) ? true : 'attributes is required', 43 | }, 44 | getOldKey: { 45 | default: ({ attributes }) => 46 | `({ ${getAttributes(attributes).join(', ')} }) => TODO`, 47 | message: 48 | 'What is the function that will get the old key from a row in the table?', 49 | extension: 'hbs', 50 | type: 'editor', 51 | }, 52 | getNewKey: { 53 | default: ({ attributes }) => 54 | `({ ${getAttributes(attributes).join(', ')} }) => TODO`, 55 | message: 56 | 'What is the function that will get the new key from a row in the table?', 57 | extension: 'hbs', 58 | type: 'editor', 59 | }, 60 | remove: { 61 | default: false, 62 | message: 'Remove rows that are missing an s3 file at oldKey?', 63 | type: 'confirm', 64 | }, 65 | }, 66 | type: 'tableMigration', 67 | }; 68 | -------------------------------------------------------------------------------- /src/checks/columnDefinitions.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import difference from 'lodash/difference'; 3 | import uniq from 'lodash/uniq'; 4 | 5 | // global 6 | import { 7 | ConfiguredModelDefinition, 8 | ModelMap, 9 | StringKeys, 10 | SyncError, 11 | } from '@wildebeest/types'; 12 | import expectedColumnNames from '@wildebeest/utils/expectedColumnNames'; 13 | import listColumns from '@wildebeest/utils/listColumns'; 14 | 15 | // classes 16 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 17 | 18 | // local 19 | import checkColumnDefinition from './columnDefinition'; 20 | 21 | /** 22 | * Check that all db model attributes defined by sequelize match the ones in postgres 23 | * 24 | * @param wildebeest - The wildebeest configuration 25 | * @param model - The db model to check against 26 | * @returns Any errors related to all column definitions in the table 27 | */ 28 | export default async function checkColumnDefinitions( 29 | wildebeest: Wildebeest, 30 | model: ConfiguredModelDefinition>, 31 | ): Promise { 32 | // Keep track of errors 33 | const errors: SyncError[] = []; 34 | 35 | // Compare columns to what exist 36 | const expectedColumns = expectedColumnNames( 37 | model.attributes, 38 | model.rawAssociations, 39 | ); 40 | const existingColumns = await listColumns(wildebeest.db, model.tableName); 41 | 42 | // Extra check 43 | const extraColumns = difference(existingColumns, expectedColumns); 44 | if (extraColumns.length > 0) { 45 | errors.push({ 46 | message: `Extra columns: "${extraColumns.join('", "')}" in table: ${ 47 | model.tableName 48 | }`, 49 | tableName: model.tableName, 50 | }); 51 | } 52 | 53 | // Missing check 54 | const missingColumns = difference(expectedColumns, existingColumns); 55 | if (missingColumns.length > 0) { 56 | errors.push({ 57 | message: `Missing columns: "${uniq(missingColumns).join( 58 | '", "', 59 | )}" in table: ${model.tableName}`, 60 | tableName: model.tableName, 61 | }); 62 | } 63 | 64 | // Check each individual column definition 65 | const columnErrors = await Promise.all( 66 | existingColumns.map((name) => 67 | checkColumnDefinition(wildebeest, model, name), 68 | ), 69 | ); 70 | 71 | return errors.concat(...columnErrors); 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/createAssociationApply.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { 3 | BelongsToAssociation, 4 | HasManyAssociation, 5 | HasOneAssociation, 6 | ObjByString, 7 | } from '@wildebeest/types'; 8 | 9 | // local 10 | import apply, { ApplyFunc } from './apply'; 11 | 12 | /** 13 | * Function to apply over associations and enforce 14 | */ 15 | type ApplyFuncForAssociation = < 16 | TInput extends ObjByString, 17 | TOutput extends TOutputBase 18 | >( 19 | obj: TInput, 20 | applyFunc: ApplyFunc, 21 | ) => { [key in keyof TInput]: TOutput }; 22 | 23 | /** 24 | * Apply functions with association 25 | */ 26 | type AssociationApply = { 27 | /** Enforce a belongsTo association */ 28 | belongsTo: ApplyFuncForAssociation>; 29 | /** Enforce a hasMany association */ 30 | hasMany: ApplyFuncForAssociation>; 31 | /** Enforce a hasOne association */ 32 | hasOne: ApplyFuncForAssociation>; 33 | }; 34 | 35 | /** 36 | * Creates a new index, main purpose is to override @types/sequelize 37 | * 38 | * @returns A set of apply functions that will enforce association types without casting their underlying values 39 | */ 40 | export default function createAssociationApply< 41 | TDatabaseModelName extends string 42 | >(): AssociationApply { 43 | const asBelongsTo = < 44 | TBelongsTo extends BelongsToAssociation 45 | >( 46 | x: TBelongsTo, 47 | ): TBelongsTo => x; 48 | 49 | const asHasMany = >( 50 | x: THasMany, 51 | ): THasMany => x; 52 | 53 | const asHasOne = >( 54 | x: THasOne, 55 | ): THasOne => x; 56 | 57 | return { 58 | belongsTo: (obj, applyFunc) => 59 | apply(obj, (value, key, fullObj, ind) => 60 | asBelongsTo(applyFunc(value, key, fullObj, ind)), 61 | ), 62 | hasMany: (obj, applyFunc) => 63 | apply(obj, (value, key, fullObj, ind) => 64 | asHasMany(applyFunc(value, key, fullObj, ind)), 65 | ), 66 | hasOne: (obj, applyFunc) => 67 | apply(obj, (value, key, fullObj, ind) => 68 | asHasOne(applyFunc(value, key, fullObj, ind)), 69 | ), 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/getNumberedList.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Numbered Folder 3 | * 4 | * A regex to check for a filename in the form xxxx-name 5 | * 6 | * i.e. 0032-my-migration 7 | */ 8 | export const NUMBERED_REGEX = /^(\d\d\d\d)-+[\w-]+(|\.js|\.ts)$/; 9 | 10 | /** 11 | * Helper function to parse out the number from a string input 12 | * 13 | * @param item - The item to pull the number from 14 | * @returns The number for that string 15 | */ 16 | export const parseNumber = (item: string): number => { 17 | if (!NUMBERED_REGEX.test(item)) { 18 | throw new Error(`Item does not match regex: "${item}"`); 19 | } 20 | 21 | return parseInt((NUMBERED_REGEX.exec(item) || [])[1], 10); 22 | }; 23 | 24 | /** 25 | * Verify that there are no duplicate numbered files and they are ordered 1, 2, ... N 26 | * 27 | * ```typescript 28 | * // Returns true 29 | * verifyNumberedFiles(['0001-file.ts', '0002-file2.ts']); 30 | * ``` 31 | * 32 | * @throws {Error} If migration files are not ordered properly 33 | * @param files - The list of files to verify 34 | * @param throwError - Throw an error if invalid 35 | * @returns When throwError is false and there is an error, this will return what index the conflicts exist at 36 | */ 37 | export function verifyNumberedFiles(files: string[], bottom = 1): number { 38 | const invalidIndex = -1; 39 | 40 | // Pull off the number 41 | files 42 | .map((fil) => fil.split('-')[0]) 43 | // Ensure that the numbers are increasing in order 44 | .forEach((num, ind) => { 45 | if (parseInt(num, 10) !== ind + bottom) { 46 | console.log(parseInt(num, 10), ind + 1); 47 | throw new Error( 48 | `Migration file naming convention wrong at ${ind + bottom}`, 49 | ); 50 | } 51 | }); 52 | 53 | // Return the invalid index if not throwing an error 54 | return invalidIndex; 55 | } 56 | 57 | /** 58 | * Given a list of potential numbered files or folders (i.e. migrations), filter for those that follow the number pattern 59 | * 60 | * @param listItems - The raw list items to filter 61 | * @returns The numbered items in order 62 | */ 63 | export default function getNumberedList( 64 | listItems: string[], 65 | bottom = 1, 66 | regex = NUMBERED_REGEX, 67 | ): string[] { 68 | // Filter by regex 69 | const migrations = listItems.filter((item) => regex.test(item)); 70 | 71 | // Verify that they are valid 72 | verifyNumberedFiles(migrations, bottom); 73 | 74 | return migrations; 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/setAssociations.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | // classes 3 | import WildebeestModel from '@wildebeest/classes/WildebeestModel'; 4 | 5 | // local 6 | import apply from './apply'; 7 | 8 | /** 9 | * Setup the associations for a db model, saving the association to the class definition of the model 10 | * 11 | * TODO Do not set names of model associations dynamically 12 | * 13 | * @param Model - The model to set the associations for 14 | * @returns Returns the model instance with associations set 15 | */ 16 | export default function setAssociations( 17 | Model: T, 18 | ): T { 19 | // Grab the associations 20 | const { wildebeest, db, configuredDefinition } = Model; 21 | 22 | // Ensure db is initialized beforehand 23 | if (!db || !wildebeest || !configuredDefinition) { 24 | throw new Error( 25 | `Db model must be initialized before calling "setAssociations"`, 26 | ); 27 | } 28 | const { associations } = configuredDefinition; 29 | 30 | // Process `hasMany` associations 31 | apply(associations.hasMany, (association, associationName) => { 32 | // Get the child model 33 | const childModel = db.model(association.modelName); 34 | 35 | // Has many relation 36 | Model.associations[wildebeest.pluralCase(associationName)] = Model.hasMany( 37 | childModel, 38 | association, 39 | ); 40 | }); 41 | 42 | // Process `hasOne` associations 43 | apply(associations.hasOne, (association, associationName) => { 44 | // Get the child model 45 | const childModel = db.model(association.modelName); 46 | 47 | // Has one child relation 48 | Model.associations[associationName] = Model.hasOne(childModel, association); 49 | }); 50 | 51 | // Process `belongsTo` associations 52 | apply(associations.belongsTo, (association, associationName) => { 53 | // Get the child model 54 | const parentModel = db.model(association.modelName); 55 | 56 | // Belongs to 57 | Model.associations[associationName] = Model.belongsTo( 58 | parentModel, 59 | association, 60 | ); 61 | }); 62 | 63 | // Process `belongsToMany` associations 64 | apply(associations.belongsToMany, (association, associationName) => { 65 | // Get the child model 66 | const associationModel = db.model(associationName); 67 | 68 | // Belongs to 69 | Model.associations[ 70 | wildebeest.pluralCase(associationName) 71 | ] = Model.belongsToMany(associationModel, association); 72 | }); 73 | 74 | return Model; 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/addTableColumnConstraint.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import sequelize from 'sequelize'; 3 | 4 | // global 5 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 6 | import { 7 | Attributes, 8 | MigrationTransactionOptions, 9 | ModelMap, 10 | } from '@wildebeest/types'; 11 | import inferTableReference from '@wildebeest/utils/inferTableReference'; 12 | 13 | /** 14 | * Options for adding a constraint to a table 15 | */ 16 | export type AddTableConstraintOptions = { 17 | /** The name of the table */ 18 | tableName: string; 19 | /** The name of the column */ 20 | columnName: string; 21 | /** The add constraint options to pass to queryInterface.addConstraint */ 22 | constraintOptions: sequelize.AddForeignKeyConstraintOptions; 23 | /** Override the name of the constraint */ 24 | constraintName?: string; 25 | /** When true, drop the constraint if it exists */ 26 | drop?: boolean; 27 | }; 28 | 29 | /** 30 | * Add a constraint on a table column 31 | * 32 | * @param db - The database to add the table constraint to 33 | * @param options - The add constraint options 34 | * @param rawTransactionOptions - The current transaction 35 | * @returns The create constraint promise 36 | */ 37 | export default async function addTableColumnConstraint< 38 | TModels extends ModelMap, 39 | TAttributes extends Attributes 40 | >( 41 | { db, namingConventions, pluralCase }: Wildebeest, 42 | options: AddTableConstraintOptions, 43 | transactionOptions: MigrationTransactionOptions, 44 | ): Promise { 45 | // Raw query interface 46 | const { queryInterface } = db; 47 | const { queryT } = transactionOptions; 48 | const { 49 | tableName, 50 | columnName, 51 | constraintOptions, 52 | constraintName, 53 | drop = false, 54 | } = options; 55 | const { references, ...otherConstraintOptions } = constraintOptions; 56 | 57 | // Determine the name of the constraint 58 | const useConstraintName = 59 | constraintName || 60 | namingConventions.foreignKeyConstraint(tableName, columnName); 61 | 62 | // Drop the constraint first 63 | if (drop) { 64 | await queryT.raw( 65 | `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${useConstraintName}";`, 66 | ); 67 | } 68 | // Add the new constraint 69 | await queryInterface.addConstraint(tableName, [columnName], { 70 | name: useConstraintName, 71 | references: references || inferTableReference(columnName, pluralCase), 72 | ...otherConstraintOptions, 73 | ...transactionOptions, 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/migrateEnumValues.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 3 | import { 4 | Attributes, 5 | MigrationTransactionOptions, 6 | ModelMap, 7 | } from '@wildebeest/types'; 8 | 9 | // local 10 | import columnAllowsNull from './columnAllowsNull'; 11 | import getColumnDefault from './getColumnDefault'; 12 | import migrateEnumColumn, { MigrateEnumOptions } from './migrateEnumColumn'; 13 | 14 | /** 15 | * Lookup the current column attributes, and migrate the values of an enum by removing the old enum and adding a new enum. 16 | * 17 | * This is done to preserve proper non-superuser postgres admin privilege during migrations. 18 | * 19 | * @param tableName - The name of the table 20 | * @param columnName - The name of the column where enum is applied 21 | * @param enumValueList - The new enum values 22 | * @param options - Specify additional options to pass to migrateEnumColumn 23 | * @returns The change enum promise 24 | */ 25 | export default async function migrateEnumValues( 26 | wildebeest: Wildebeest, 27 | enumValueList: string[], 28 | migrateEnumOptions: MigrateEnumOptions, 29 | transactionOptions: MigrationTransactionOptions, 30 | ): Promise { 31 | const { tableName, columnName } = migrateEnumOptions; 32 | 33 | // Build the new enum values 34 | const enumValue = enumValueList.reduce( 35 | (acc, val) => Object.assign(acc, { [val]: val }), 36 | {}, 37 | ); 38 | 39 | // Keep the same allowNull and defaultValue 40 | const [allowNull, defaultValue] = await Promise.all([ 41 | columnAllowsNull(wildebeest.db, tableName, columnName, transactionOptions), 42 | getColumnDefault(wildebeest.db, tableName, columnName, transactionOptions), 43 | ]); 44 | 45 | // Determine the new default value (it is either the existing defaultValue or the converted default value) 46 | const calculatedDefaultValue = 47 | defaultValue && defaultValue.includes('::') 48 | ? defaultValue.split('::')[0].split("'").join('') 49 | : ''; 50 | 51 | // Check if the default value needs to be converted 52 | const { convertEnum = {} } = migrateEnumOptions; 53 | const convertedDefaultValue = 54 | convertEnum[calculatedDefaultValue] || calculatedDefaultValue; 55 | 56 | // Migrate the column 57 | await migrateEnumColumn( 58 | wildebeest, 59 | enumValue, 60 | { 61 | ...migrateEnumOptions, 62 | allowNull, 63 | defaultValue: convertedDefaultValue, 64 | }, 65 | transactionOptions, 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/migrationTypes/cascadeWithParent.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { OnDelete } from '@wildebeest/enums'; 3 | import { MigrationDefinition, ModelMap } from '@wildebeest/types'; 4 | import addTableColumnConstraint from '@wildebeest/utils/addTableColumnConstraint'; 5 | import { TableReference } from '@wildebeest/utils/inferTableReference'; 6 | 7 | /** 8 | * Options for changing the cascade on a foreign key 9 | */ 10 | export type CascadeWithParentOptions = { 11 | /** The name of the table to add the cascade on */ 12 | tableName: string; 13 | /** The name of the column on the table that should be cascading */ 14 | columnName: string; 15 | /** The references for the table where the constraint is made */ 16 | references?: TableReference; 17 | /** The name of the constraint, if left null will be inferred */ 18 | constraintName?: string; 19 | /** What should occur when parent is deleted */ 20 | onDelete?: OnDelete; 21 | }; 22 | 23 | /** 24 | * Update a column to have an on delete cascade when it's parent is cascaded 25 | * 26 | * @param options - The add cascade with parent migrator options 27 | * @returns The cascade with parent migrator 28 | */ 29 | export default function cascadeWithParent( 30 | options: CascadeWithParentOptions, 31 | ): MigrationDefinition { 32 | const { 33 | tableName, 34 | columnName, 35 | references, 36 | constraintName, 37 | onDelete = 'cascade', 38 | } = options; 39 | return { 40 | up: async (wildebeest, withTransaction) => 41 | withTransaction((transactionOptions) => 42 | addTableColumnConstraint( 43 | wildebeest, 44 | { 45 | tableName, 46 | columnName, 47 | constraintOptions: { 48 | type: 'foreign key', 49 | onDelete, 50 | onUpdate: 'cascade', 51 | references, 52 | }, 53 | constraintName, 54 | drop: true, 55 | }, 56 | transactionOptions, 57 | ), 58 | ), 59 | down: async (wildebeest, withTransaction) => 60 | withTransaction((transactionOptions) => 61 | addTableColumnConstraint( 62 | wildebeest, 63 | { 64 | tableName, 65 | columnName, 66 | constraintOptions: { 67 | type: 'foreign key', 68 | onDelete: 'set null', 69 | onUpdate: 'cascade', 70 | references, 71 | }, 72 | constraintName, 73 | drop: true, 74 | }, 75 | transactionOptions, 76 | ), 77 | ), 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/checks/uniqueConstraint.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import { ModelAttributeColumnOptions, QueryTypes } from 'sequelize'; 3 | 4 | // global 5 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 6 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 7 | import { ModelMap, SyncError } from '@wildebeest/types'; 8 | 9 | /** 10 | * Check if the database has a unique constraint 11 | * 12 | * @param db - The db instance 13 | * @param tableName - The name of the table 14 | * @param name - The name of the constraint 15 | * @returns True if the constraint is defined 16 | */ 17 | export async function hasUniqueConstraint( 18 | db: WildebeestDb, 19 | tableName: string, 20 | name: string, 21 | ): Promise { 22 | const [constraint] = await db.queryInterface.sequelize.query( 23 | ` 24 | SELECT constraint_name,table_name 25 | FROM information_schema.constraint_column_usage 26 | WHERE table_name = '${tableName}' and "constraint_name" = '${name}' 27 | `, 28 | { 29 | type: QueryTypes.SELECT, 30 | }, 31 | ); 32 | return !!constraint; 33 | } 34 | 35 | /** 36 | * Ensure a unique constraint is set properly 37 | * 38 | * @param model - The db model to check 39 | * @param name - The name of the attribute 40 | * @param definition - The attribute definition 41 | * @returns Any errors related to unique constraints 42 | */ 43 | export default async function checkUniqueConstraint( 44 | { namingConventions, db }: Wildebeest, 45 | tableName: string, 46 | name: string, 47 | definition: ModelAttributeColumnOptions, 48 | ): Promise { 49 | // Keep track of errors 50 | const errors: SyncError[] = []; 51 | 52 | // Check if expected to be unique 53 | const isUnique = !!definition.unique && !definition.primaryKey; 54 | 55 | // The name of the constraint 56 | const uniqueConstraintName = namingConventions.uniqueConstraint( 57 | tableName, 58 | name, 59 | ); 60 | 61 | // Check if the constraint exists 62 | const constraintExists = await hasUniqueConstraint( 63 | db, 64 | tableName, 65 | uniqueConstraintName, 66 | ); 67 | 68 | // Determine if a constraint exists when it should not 69 | if (!isUnique && constraintExists) { 70 | errors.push({ 71 | message: `Has unexpected constraint: "${uniqueConstraintName}"`, 72 | tableName, 73 | }); 74 | } 75 | 76 | // Determine if a constraint does not exist when expected 77 | if (isUnique && !constraintExists) { 78 | errors.push({ 79 | message: `Missing expected constraint: "${uniqueConstraintName}"`, 80 | tableName, 81 | }); 82 | } 83 | 84 | return errors; 85 | } 86 | -------------------------------------------------------------------------------- /src/migrationTypes/addCascadeWithParent.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { OnDelete } from '@wildebeest/enums'; 3 | import { MigrationDefinition, ModelMap } from '@wildebeest/types'; 4 | import addTableColumnConstraint from '@wildebeest/utils/addTableColumnConstraint'; 5 | import { TableReference } from '@wildebeest/utils/inferTableReference'; 6 | 7 | /** 8 | * Options for making a column cascade with its parent 9 | */ 10 | export type CascadeWithParentOptions = { 11 | /** The name of the table to add the cascade to */ 12 | tableName: string; 13 | /** The name of the column in the table that needs the constraint */ 14 | columnName: string; 15 | /** Specify the foreign key constraint manually */ 16 | references?: TableReference; 17 | /** The name of the constraint, if left null will be inferred */ 18 | constraintName?: string; 19 | /** The on delete setting for the constraint */ 20 | onDelete?: OnDelete; 21 | }; 22 | 23 | /** 24 | * Add a constraint to a column to have an on delete cascade when it's parent is cascaded 25 | * 26 | * @param options - The add cascade with parent migrator options 27 | * @returns The add cascade with parent migrator 28 | */ 29 | export default function addCascadeWithParent( 30 | options: CascadeWithParentOptions, 31 | ): MigrationDefinition { 32 | const { 33 | tableName, 34 | columnName, 35 | references, 36 | constraintName, 37 | onDelete = 'CASCADE', 38 | } = options; 39 | return { 40 | // Add the constraint 41 | up: async (wildebeest, withTransaction) => 42 | withTransaction((transactionOptions) => 43 | addTableColumnConstraint( 44 | wildebeest, 45 | { 46 | tableName, 47 | columnName, 48 | constraintOptions: { 49 | type: 'foreign key', 50 | onDelete, 51 | onUpdate: 'CASCADE', 52 | references, 53 | }, 54 | constraintName: 55 | constraintName || 56 | wildebeest.namingConventions.foreignKeyConstraint( 57 | tableName, 58 | columnName, 59 | ), 60 | }, 61 | transactionOptions, 62 | ), 63 | ), 64 | // Remove the constraint 65 | down: async (wildebeest, withTransaction) => 66 | withTransaction(({ queryT }) => 67 | queryT.raw( 68 | `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${ 69 | constraintName || 70 | wildebeest.namingConventions.foreignKeyConstraint( 71 | tableName, 72 | columnName, 73 | ) 74 | }";`, 75 | ), 76 | ), 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/checks/belongsToAssociation.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 3 | import { 4 | BelongsToAssociation, 5 | ModelMap, 6 | StringKeys, 7 | SyncError, 8 | } from '@wildebeest/types'; 9 | 10 | /** 11 | * Validate a single belongsTo association 12 | * 13 | * @param wildebeest - The wildebeest configuration 14 | * @param modelName - The name of the model being checked 15 | * @param tableName - The table of the model being checked 16 | * @param association - The association configuration 17 | * @param name - The name of the association 18 | * @returns True if belongs to association is valiid 19 | */ 20 | export default function checkBelongsToAssociation( 21 | wildebeest: Wildebeest, 22 | modelName: StringKeys, 23 | tableName: string, 24 | association: BelongsToAssociation>, 25 | associationName: string, 26 | ): SyncError[] { 27 | // Keep track of errors 28 | const errors: SyncError[] = []; 29 | 30 | // The name of the current association 31 | const associationModelName = 32 | typeof association === 'object' && association.modelName 33 | ? association.modelName 34 | : (associationName as StringKeys); 35 | 36 | // Get the associations of the association 37 | const { 38 | associations = {}, 39 | dontMatchBelongsTo, 40 | } = wildebeest.getModelDefinition(associationModelName); 41 | 42 | // Can skip if dont need to match it 43 | if (dontMatchBelongsTo) { 44 | return errors; 45 | } 46 | 47 | const { hasOne = {}, hasMany = {} } = associations; 48 | 49 | // Check if there is a hasOne or a hasMany 50 | const hasOneAssociation = 51 | hasOne[modelName] || 52 | Object.values(hasOne).find( 53 | (a) => typeof a === 'object' && a.modelName === modelName, 54 | ); 55 | const hasManyAssociation = 56 | hasMany[modelName] || 57 | Object.values(hasMany).find( 58 | (a) => typeof a === 'object' && a.modelName === modelName, 59 | ); 60 | 61 | // Log errors 62 | const possibleOpposites = 'hasOne or hasMany'; 63 | const identifier = `Model "${associationModelName}"${ 64 | associationName === associationModelName ? '' : ` (as "${associationName}")` 65 | }`; 66 | if (hasOneAssociation && hasManyAssociation) { 67 | errors.push({ 68 | message: `${identifier} has multiple ${possibleOpposites} associations to "${modelName}"`, 69 | tableName, 70 | }); 71 | } else if (!hasOneAssociation && !hasManyAssociation) { 72 | errors.push({ 73 | message: `${identifier} is missing a ${possibleOpposites} association to "${modelName}"`, 74 | tableName, 75 | }); 76 | } 77 | return errors; 78 | } 79 | -------------------------------------------------------------------------------- /generateMixins/helpers/serializeAssociationType.ts: -------------------------------------------------------------------------------- 1 | // external 2 | import get from 'lodash/get'; 3 | 4 | // global 5 | import { AssociationDefinition } from '../types'; 6 | 7 | /** 8 | * Get the value of a string from an object 9 | */ 10 | export function getValueFromObject( 11 | innerMap: any, 12 | attributeName: string, 13 | ): string | undefined { 14 | if (!innerMap) { 15 | return undefined; 16 | } 17 | 18 | // Get the definition 19 | const attributeObj = innerMap.get(attributeName); 20 | if (!attributeObj) { 21 | return undefined; 22 | } 23 | 24 | // Determine what the provided modelName is 25 | const attributeDefinition = get( 26 | attributeObj, 27 | 'valueDeclaration.initializer.text', 28 | ); 29 | if (typeof attributeDefinition !== 'string') { 30 | return undefined; 31 | } 32 | 33 | return attributeDefinition; 34 | } 35 | 36 | /** 37 | * Process a member of the type definition (known types in the case of associations) 38 | * 39 | * @param member - The association type to process (i.e. inner symbol for `belongsTo`) 40 | * @returns A mapping from association name to model name 41 | */ 42 | export default function serializeAssociationType( 43 | member: any, 44 | ): { [k in string]: AssociationDefinition } { 45 | // Grab the child types 46 | const innerMembers = get(member, 'type.members'); 47 | 48 | // If there are no members return empty object 49 | if (!innerMembers) { 50 | return {}; 51 | } 52 | const results: { [k in string]: AssociationDefinition } = {}; 53 | innerMembers.forEach(({ type }: any, key: string) => { 54 | // The inner members of the type if an object 55 | const innerMap = get(type, 'symbol.members'); 56 | 57 | // Determine the name of the primary key column joining the tables 58 | const foreignKeyMap = get( 59 | innerMap && innerMap.get('foreignKey'), 60 | 'valueDeclaration.initializer.symbol.members', 61 | ); 62 | 63 | // Defaults to ${modelName}Id 64 | const primaryKeyName = 65 | get( 66 | foreignKeyMap && foreignKeyMap.get('name'), 67 | 'valueDeclaration.initializer.text', 68 | ) || `${key}Id`; 69 | 70 | // If the association is defined with a string, then the model name is the same as the key 71 | if (typeof type.value === 'string') { 72 | results[key] = { modelName: key, primaryKeyName }; 73 | } else { 74 | const modelName = getValueFromObject(innerMap, 'modelName'); 75 | const throughModelName = getValueFromObject(innerMap, 'throughModelName'); 76 | results[key] = 77 | typeof modelName === 'string' 78 | ? { modelName, primaryKeyName, throughModelName } 79 | : { modelName: key, primaryKeyName, throughModelName }; 80 | } 81 | }); 82 | 83 | return results; 84 | } 85 | -------------------------------------------------------------------------------- /src/checks/columnType.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | // external 3 | import { ModelAttributeColumnOptions, QueryTypes } from 'sequelize'; 4 | 5 | // global 6 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 7 | import { ModelMap, SyncError } from '@wildebeest/types'; 8 | 9 | /** 10 | * Mapping from sequelize type to data type 11 | */ 12 | export const SEQUELIZE_TO_DATA_TYPE: { [k in string]: string } = { 13 | BLOB: 'bytea', 14 | BOOLEAN: 'boolean', 15 | DATE: 'timestamp with time zone', 16 | ENUM: 'USER-DEFINED', 17 | FLOAT: 'double precision', 18 | INTEGER: 'integer', 19 | STRING: 'character varying', 20 | JSONB: 'jsonb', 21 | TEXT: 'text', 22 | UUID: 'uuid', 23 | }; 24 | 25 | /** 26 | * Determine the type of a column in the db 27 | * 28 | * @param db - The database to operate on 29 | * @param tableName - The name of the table 30 | * @param columnName - The name of the column 31 | * @returns The type of the column in postgres 32 | */ 33 | export async function getColumnType( 34 | db: WildebeestDb, 35 | tableName: string, 36 | columnName: string, 37 | ): Promise { 38 | // Get the type for the column 39 | const [{ data_type }] = await db.queryInterface.sequelize.query( 40 | ` 41 | SELECT column_name, data_type 42 | FROM information_schema.columns 43 | WHERE table_name = '${tableName}' AND column_name = '${columnName}'; 44 | `, 45 | { 46 | type: QueryTypes.SELECT, 47 | }, 48 | ); 49 | 50 | return data_type; 51 | } 52 | 53 | /** 54 | * Check that the type of a column matches postgres 55 | * 56 | * @param db - The db to test on 57 | * @param tableName - The name of the table to operate on 58 | * @param name - The name of the column 59 | * @param definition - The model definition 60 | * @returns Any errors related to the type of the column 61 | */ 62 | export default async function checkColumnType( 63 | db: WildebeestDb, 64 | tableName: string, 65 | name: string, 66 | definition: ModelAttributeColumnOptions, 67 | ): Promise { 68 | // Keep track of errors 69 | const errors: SyncError[] = []; 70 | 71 | // Get the current type 72 | const currentType = await getColumnType(db, tableName, name); 73 | 74 | // Get the expected type 75 | const expectedConstructorName = definition.type.constructor.name; 76 | const expectedType = 77 | SEQUELIZE_TO_DATA_TYPE[expectedConstructorName] || expectedConstructorName; 78 | 79 | // Check if type is correct 80 | if (expectedType !== currentType) { 81 | errors.push({ 82 | message: `Unexpected type for column: "${name}" on table "${tableName}". Got "${currentType}" expected "${expectedType}"`, // eslint-disable-line max-len 83 | tableName, 84 | }); 85 | } 86 | 87 | return errors; 88 | } 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@transcend-io/wildebeest", 3 | "version": "4.1.3", 4 | "description": "Type-safe sequelize with a simplified migration framework", 5 | "main": "build/src/index.js", 6 | "homepage": "https://github.com/transcend-io/wildebeest#readme", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/transcend-io/wildebeest.git" 10 | }, 11 | "author": "Transcend Privacy Inc.", 12 | "license": "MIT", 13 | "bugs": "https://github.com/transcend-io/wildebeest/issues", 14 | "files": [ 15 | "build/**/*", 16 | "src/**/*", 17 | "tsconfig.json" 18 | ], 19 | "engines": { 20 | "npm": ">=6.4.1", 21 | "node": ">=10.15.0" 22 | }, 23 | "bin": { 24 | "reorder-migrations": "./build/scripts/reorder_migrations.js" 25 | }, 26 | "moduleSystem": "typescript", 27 | "private": false, 28 | "scripts": { 29 | "####### Linting #######": "", 30 | "lint": "eslint . --cache --ext .js,.ts,.tsx,.jsx", 31 | "lint:fix": "eslint . --cache --fix --ext .js,.ts,.tsx,.jsx", 32 | "ts:lint": "tslint ./src/**/*.ts", 33 | "####### Testing #######": "npm run build && npm run test:nobuild", 34 | "test": "echo no tests", 35 | "test:nobuild": "NODE_ENV=test ./node_modules/.bin/mocha \"build/**/*.test.{js,ts}\" --reporter spec --timeout 10000", 36 | "####### Build #######": "", 37 | "prepare": "npm run build", 38 | "build": "tsc && copy './generateMixins/{**/*.hbs,**/*.json}' build/generateMixins && copy './src/{**/*.hbs,**/*.json}' build/src && node ./fix_absolute_imports.js", 39 | "build:watch": "tsc --watch" 40 | }, 41 | "peerDependencies": { 42 | "sequelize": "^5.21.6" 43 | }, 44 | "dependencies": { 45 | "aws-sdk": "^2.690.0", 46 | "bluebird": "=3.7.2", 47 | "change-case": "4.1.1", 48 | "express": "^4.17.1", 49 | "handlebars": "^4.7.6", 50 | "lodash": "^4.17.15", 51 | "pluralize": "^8.0.0", 52 | "umzug": "^3.0.0-beta.5" 53 | }, 54 | "devDependencies": { 55 | "@types/express": "^4.17.4", 56 | "@types/lodash": "^4.14.155", 57 | "@types/mocha": "^7.0.2", 58 | "@types/node": "^14.0.11", 59 | "@types/pluralize": "0.0.29", 60 | "@types/umzug": "^2.2.3", 61 | "@types/uuid": "^8.0.0", 62 | "@typescript-eslint/eslint-plugin": "^3.1.0", 63 | "@typescript-eslint/parser": "^3.1.0", 64 | "babel-eslint": "^10.1.0", 65 | "copy": "^0.3.2", 66 | "eslint": "^7.1.0", 67 | "eslint-config-airbnb-base": "^14.1.0", 68 | "eslint-config-prettier": "^6.10.1", 69 | "eslint-import-resolver-typescript": "^2.0.0", 70 | "eslint-plugin-import": "^2.20.2", 71 | "eslint-plugin-prettier": "^3.1.2", 72 | "mocha": "^7.2.0", 73 | "prettier": "^2.0.4", 74 | "sequelize": "5.21.12", 75 | "tslint": "^6.1.1", 76 | "tslint-microsoft-contrib": "^6.2.0", 77 | "typescript": "3.9.5" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/checks/joinBelongsTo.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 3 | import { 4 | ConfiguredModelDefinition, 5 | ModelMap, 6 | StringKeys, 7 | SyncError, 8 | } from '@wildebeest/types'; 9 | 10 | /** 11 | * Check that the associations are correct for a `belongsTo` between two tables that are joining 12 | * 13 | * @param db - The wildebeest db 14 | * @param model - The model to check the associations for 15 | * @returns Any errors related to the belongs to association config of a join table 16 | */ 17 | export default async function checkJoinBelongsTo( 18 | db: WildebeestDb, 19 | { associations, tableName }: ConfiguredModelDefinition>, 20 | ): Promise { 21 | // Keep track of errors 22 | const errors: SyncError[] = []; 23 | 24 | // Ensure db is defined 25 | if (!db) { 26 | // TODO maybe have a type that has these enforced to be set to prevent need for type check all over 27 | throw new Error( 28 | `wildebeest instance must be initialized before checking belongsTo`, 29 | ); 30 | } 31 | 32 | // The belongs to associations 33 | const { belongsTo } = associations; 34 | 35 | // Join tables must have belongsToMany specified by each of its belongsTo 36 | // Ensure that there are two belongsTo association 37 | if (Object.values(belongsTo).length < 2) { 38 | errors.push({ 39 | message: `Join table: "${tableName}" expected to have at least 2 belongsTo associations`, 40 | tableName, 41 | }); 42 | } else { 43 | // Joins between these models 44 | // TODO expecting to be first two there 45 | const [first, second] = Object.keys(belongsTo) as Extract< 46 | keyof TModels, 47 | string 48 | >[]; 49 | 50 | // Helper to check if belongsToMany config is setup 51 | const hasBelongsToConfig = ( 52 | firstModelName: StringKeys, 53 | secondModelName: StringKeys, 54 | ): boolean => { 55 | const oppositeAssociations = db.model(firstModelName).definition 56 | .associations; 57 | return ( 58 | !!oppositeAssociations && 59 | !!oppositeAssociations.belongsToMany && 60 | secondModelName in oppositeAssociations.belongsToMany 61 | ); 62 | }; 63 | 64 | // Check if the first table is setup 65 | const firstValid = hasBelongsToConfig(first, second); 66 | if (!firstValid) { 67 | errors.push({ 68 | message: `Table "${first}" missing belongsToMany "${second}"`, 69 | tableName, 70 | }); 71 | } 72 | 73 | // Check if the second table is setup 74 | const secondValid = hasBelongsToConfig(second, first); 75 | if (!secondValid) { 76 | errors.push({ 77 | message: `Table "${second}" missing belongsToMany "${first}"`, 78 | tableName, 79 | }); 80 | } 81 | } 82 | return errors; 83 | } 84 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'airbnb-base', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'prettier', 7 | 'prettier/@typescript-eslint', 8 | 'plugin:prettier/recommended', 9 | // "@transcend-io/transcend", 10 | ], 11 | env: { 12 | browser: true, 13 | node: true, 14 | es6: true, 15 | mocha: true, 16 | }, 17 | plugins: ['import', '@typescript-eslint'], 18 | parserOptions: { 19 | ecmaVersion: 6, 20 | sourceType: 'module', 21 | ecmaFeatures: { 22 | jsx: true, 23 | }, 24 | }, 25 | rules: { 26 | '@typescript-eslint/prefer-interface': 0, 27 | '@typescript-eslint/indent': 0, 28 | '@typescript-eslint/explicit-function-return-type': [ 29 | 'warn', 30 | { allowExpressions: true }, 31 | ], 32 | '@typescript-eslint/camelcase': 0, 33 | '@typescript-eslint/interface-name-prefix': 0, 34 | '@typescript-eslint/no-var-requires': 0, 35 | 'template-curly-spacing': 0, 36 | '@typescript-eslint/no-use-before-define': 0, 37 | 'arrow-parens': ['error', 'always'], 38 | 'arrow-body-style': [2, 'as-needed'], 39 | 'class-methods-use-this': ['error'], 40 | 'comma-dangle': 0, // [2, "always-multiline"] handled by prettier 41 | 'function-paren-newline': 0, 42 | 'import/imports-first': ['error'], 43 | 'import/extensions': 0, 44 | 'import/newline-after-import': ['error'], 45 | 'import/no-dynamic-require': ['error'], 46 | 'import/no-extraneous-dependencies': 0, 47 | 'import/no-named-as-default': 0, 48 | 'import/order': 0, 49 | // "import/no-relative-parent-imports": ["warn"], 50 | 'import/no-unresolved': 2, 51 | 'import/no-webpack-loader-syntax': ['error'], 52 | 'import/prefer-default-export': 0, 53 | indent: 0, 54 | // "jsdoc/check-types": ["error"], 55 | 'max-len': ['error', 125, { comments: 200 }], 56 | 'max-lines': ['error', 350], 57 | // "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 }], 58 | 'no-confusing-arrow': 0, 59 | 'no-console': 0, 60 | 'no-underscore-dangle': 0, 61 | 'no-multi-spaces': ['error'], 62 | 'no-use-before-define': ['error'], 63 | // "object-curly-newline": ["error", { "multiline": true, "minProperties": 4, "consistent": true }], 64 | 'object-property-newline': 'error', 65 | 'prefer-template': 2, 66 | 'require-jsdoc': [ 67 | 'error', 68 | { 69 | require: { 70 | FunctionDeclaration: true, 71 | MethodDefinition: true, 72 | ClassDeclaration: true, 73 | ArrowFunctionExpression: false, 74 | FunctionExpression: true, 75 | }, 76 | }, 77 | ], 78 | 'require-yield': ['error'], 79 | 'sort-vars': ['error', { ignoreCase: true }], 80 | }, 81 | settings: { 82 | 'import/resolver': { 83 | typescript: {}, 84 | }, 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /src/checks/associationConfig.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | // external 3 | import { QueryTypes } from 'sequelize'; 4 | 5 | // global 6 | import { ConfiguredAttribute, ModelMap, SyncError } from '@wildebeest/types'; 7 | import getForeignKeyConfig from '@wildebeest/utils/getForeignKeyConfig'; 8 | 9 | // local 10 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 11 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 12 | 13 | /** 14 | * Check if the database has a constraint 15 | * 16 | * @param db - The db instance 17 | * @param name - The name of the constraint 18 | * @returns True if the constraint is defined 19 | */ 20 | export async function hasConstraint( 21 | db: WildebeestDb, 22 | name: string, 23 | ): Promise { 24 | const [constraint] = await db.queryInterface.sequelize.query( 25 | ` 26 | SELECT conname FROM pg_constraint WHERE conname='${name}' 27 | `, 28 | { 29 | type: QueryTypes.SELECT, 30 | }, 31 | ); 32 | return !!constraint; 33 | } 34 | 35 | /** 36 | * Ensure the constraint between the table column associations is valid 37 | * 38 | * @param wildebeest - The wildebeest configuration 39 | * @param tableName - The name of the table to check 40 | * @param name - The name of the column 41 | * @param definition - The attribute definition 42 | * @returns Any errors with the association configuration 43 | */ 44 | export default async function checkAssociationConfig( 45 | { namingConventions, db }: Wildebeest, 46 | tableName: string, 47 | name: string, 48 | definition: ConfiguredAttribute, 49 | ): Promise { 50 | // Keep track of errors 51 | const errors: SyncError[] = []; 52 | 53 | // The name of the constraint 54 | const constraintName = namingConventions.foreignKeyConstraint( 55 | tableName, 56 | name, 57 | ); 58 | 59 | // Ensure that the constraint exists 60 | const constraintExists = await hasConstraint(db, constraintName); 61 | if (!constraintExists) { 62 | errors.push({ 63 | message: `Missing foreign key constraint for "${name}" on table "${tableName}": "${constraintName}"`, 64 | tableName, 65 | }); 66 | } else { 67 | // Ensure the constraint has proper onDelete 68 | const { delete_rule } = await getForeignKeyConfig(db, constraintName); 69 | const expected = 70 | definition.associationOptions && definition.associationOptions.onDelete 71 | ? definition.associationOptions.onDelete.toUpperCase() 72 | : 'NO ACTION'; 73 | 74 | if (delete_rule !== expected) { 75 | errors.push({ 76 | message: `Invalid foreign key onDelete for column "${name}" of table "${tableName}". Got "${delete_rule}" expected "${expected}"`, // eslint-disable-line max-len 77 | tableName, 78 | }); 79 | } 80 | } 81 | 82 | return errors; 83 | } /* eslint-enable camelcase */ 84 | -------------------------------------------------------------------------------- /src/migrationTypes/renameConstraint.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import { 3 | MigrationDefinition, 4 | MigrationTransactionOptions, 5 | ModelMap, 6 | } from '@wildebeest/types'; 7 | import listConstraintNames from '@wildebeest/utils/listConstraintNames'; 8 | 9 | // classes 10 | import WildebeestDb from '@wildebeest/classes/WildebeestDb'; 11 | 12 | /** 13 | * Options for changing the name of a constraint 14 | */ 15 | export type RenameConstraintOptions = { 16 | /** The name of the table to rename the constraint on */ 17 | tableName: string; 18 | /** The old name of the constraint */ 19 | oldName: string; 20 | /** The new name of the constraint */ 21 | newName: string; 22 | /** If the oldName does not exist, simply skip this transformation (for backwards compatibility of migrations) */ 23 | skipIfExists?: boolean; 24 | }; 25 | 26 | /** 27 | * Change the name of a constraint 28 | * 29 | * @param db - The database to migrate against 30 | * @param options - The options to use to rename from 31 | * @param rawTransactionOptions - The previous transaction options 32 | * @returns The change constraint promise 33 | */ 34 | export async function changeConstraintName( 35 | options: RenameConstraintOptions, 36 | transactionOptions: MigrationTransactionOptions, 37 | db: WildebeestDb, 38 | ): Promise { 39 | // Raw query interface 40 | const { queryT } = transactionOptions; 41 | const { tableName, oldName, newName, skipIfExists } = options; 42 | 43 | // If allowed to skip and constraint exists, skip it 44 | if (skipIfExists) { 45 | const constraintNames = await listConstraintNames( 46 | db, 47 | tableName, 48 | transactionOptions, 49 | ); 50 | if (constraintNames.includes(newName)) { 51 | return; 52 | } 53 | } 54 | 55 | // Rename the constraint 56 | await queryT.raw( 57 | ` 58 | ALTER TABLE "${tableName}" RENAME CONSTRAINT "${oldName}" TO "${newName}"; 59 | `, 60 | ); 61 | } 62 | 63 | /** 64 | * Change the name of a constraint 65 | * 66 | * @param options - Options for renaming a constraint 67 | * @returns The rename constraint migrator 68 | */ 69 | export default function renameConstraint( 70 | options: RenameConstraintOptions, 71 | ): MigrationDefinition { 72 | const { oldName, newName, ...rest } = options; 73 | return { 74 | up: async (wildebeest, withTransaction) => 75 | withTransaction((transactionOptions) => 76 | changeConstraintName( 77 | { oldName, newName, ...rest }, 78 | transactionOptions, 79 | wildebeest.db, 80 | ), 81 | ), 82 | down: async (wildebeest, withTransaction) => 83 | withTransaction((transactionOptions) => 84 | changeConstraintName( 85 | { newName: oldName, oldName: newName, ...rest }, 86 | transactionOptions, 87 | wildebeest.db, 88 | ), 89 | ), 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /src/checks/columnDefinition.ts: -------------------------------------------------------------------------------- 1 | // global 2 | import Wildebeest from '@wildebeest/classes/Wildebeest'; 3 | import { 4 | ConfiguredModelDefinition, 5 | ModelMap, 6 | StringKeys, 7 | SyncError, 8 | } from '@wildebeest/types'; 9 | import isEnum from '@wildebeest/utils/isEnum'; 10 | 11 | // local 12 | import checkAllowNullConstraint from './allowNullConstraint'; 13 | import checkAssociationConfig from './associationConfig'; 14 | import checkColumnType from './columnType'; 15 | import checkDefaultValue from './defaultValue'; 16 | import checkEnumDefinition from './enumDefinition'; 17 | import checkPrimaryKeyDefinition from './primaryKeyDefinition'; 18 | import checkUniqueConstraint from './uniqueConstraint'; 19 | 20 | /** 21 | * Check that a db model column definition defined by sequelize is matching the postgres db 22 | * 23 | * @param wildebeest - The wildebeest configuration 24 | * @param model - The model to check 25 | * @param name - The name of the column to check 26 | * @returns Any errors related to the column definition 27 | */ 28 | export default async function checkColumnDefinition( 29 | wildebeest: Wildebeest, 30 | { 31 | tableName, 32 | attributes, 33 | rawAttributes, 34 | }: ConfiguredModelDefinition>, 35 | name: string, 36 | ): Promise { 37 | // Keep track of errors 38 | const errors: SyncError[] = []; 39 | 40 | // Get the column definition 41 | const definition = attributes[name]; 42 | const rawDefinition = rawAttributes[name]; 43 | if (!definition) { 44 | errors.push({ 45 | message: `Missing attribute definition for column "${name}" in table "${tableName}"`, 46 | tableName, 47 | }); 48 | return errors; 49 | } 50 | 51 | // Run the column checks 52 | const allErrors = await Promise.all([ 53 | // If the definition is an enum, verify that the enum configuration is correct 54 | !isEnum(definition) 55 | ? [] 56 | : checkEnumDefinition(wildebeest, tableName, name, definition), 57 | // If the column is a primary key, ensure its constraint exists 58 | !definition.primaryKey 59 | ? [] 60 | : checkPrimaryKeyDefinition(wildebeest, tableName, name), 61 | // Ensure the association is configured properly 62 | !definition.isAssociation 63 | ? [] 64 | : checkAssociationConfig(wildebeest, tableName, name, definition), 65 | // Ensure the unique constraint is set properly 66 | rawDefinition 67 | ? checkUniqueConstraint(wildebeest, tableName, name, rawDefinition) 68 | : [], 69 | // Ensure allowNull is set properly 70 | checkAllowNullConstraint(wildebeest.db, tableName, name, definition), 71 | // Ensure the default value is correct 72 | checkDefaultValue(wildebeest, tableName, name, definition), 73 | // Ensure the type is set properly, 74 | checkColumnType(wildebeest.db, tableName, name, definition), 75 | ]); 76 | 77 | return errors.concat(...allErrors); 78 | } 79 | --------------------------------------------------------------------------------