├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.js ├── .github └── FUNDING.yml ├── .gitignore ├── .nodepack ├── .gitignore ├── README.md ├── app-migration-plugin-versions.json └── app-migration-records.json ├── LICENSE ├── README.md ├── docs ├── .vuepress │ ├── config.js │ ├── override.styl │ ├── public │ │ ├── favicon.png │ │ ├── logo-small.png │ │ └── logo.png │ └── style.styl ├── README.md └── guide │ ├── README.md │ ├── cli.md │ ├── compatibility.md │ ├── cookbook.md │ ├── internal-schema.md │ ├── name-transforms.md │ ├── options.md │ ├── plugins.md │ └── scalar-mapping.md ├── graphql-migrate.png ├── graphql-migrate.svg ├── jest.config.js ├── nodepack.config.js ├── package.json ├── src ├── abstract │ ├── AbstractDatabase.ts │ ├── Table.ts │ ├── TableColumn.ts │ ├── generateAbstractDatabase.ts │ └── getColumnTypeFromScalar.ts ├── connector │ ├── read.ts │ └── write.ts ├── diff │ ├── Operation.ts │ └── computeDiff.ts ├── index.ts ├── migrate.ts ├── plugin │ └── MigratePlugin.ts └── util │ ├── comments.ts │ ├── defaultNameTransforms.ts │ ├── getCheckConstraints.ts │ ├── getColumnComments.ts │ ├── getForeignKeys.ts │ ├── getIndexes.ts │ ├── getPrimaryKey.ts │ ├── getTypeAlias.ts │ ├── getUniques.ts │ ├── listTables.ts │ ├── sortOps.ts │ └── transformDefaultValue.ts ├── tests └── specs │ ├── abstract-database.ts │ ├── diff.ts │ └── sortOps.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: circleci/node:11.8.0 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | keys: 11 | - yarn-{{ .Branch }}-{{ checksum "yarn.lock" }} 12 | - yarn-{{ .Branch }}- 13 | - yarn 14 | - run: yarn --network-timeout 600000 15 | - save_cache: 16 | key: yarn-{{ .Branch }}-{{ checksum "yarn.lock" }} 17 | paths: 18 | - node_modules/ 19 | - run: yarn tslint 20 | - run: yarn eslint 21 | - run: yarn test:types 22 | - run: yarn test:unit 23 | - run: yarn build 24 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | !.* 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'standard', 4 | ], 5 | parser: '@typescript-eslint/parser', 6 | plugins: [ 7 | 'node', 8 | '@typescript-eslint', 9 | ], 10 | env: { 11 | 'jest': true, 12 | }, 13 | rules: { 14 | 'indent': ['error', 2, { 15 | 'MemberExpression': 'off', 16 | }], 17 | 'node/no-extraneous-require': ['error'], 18 | 'comma-dangle': ['error', 'always-multiline'], 19 | 'no-unused-vars': 'off', 20 | '@typescript-eslint/no-unused-vars': 'error', 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: Akryum 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /.nodepack/.gitignore: -------------------------------------------------------------------------------- 1 | /temp 2 | -------------------------------------------------------------------------------- /.nodepack/README.md: -------------------------------------------------------------------------------- 1 | # Nodepack internal config files 2 | 3 | Add this to version control. Modify at yourn own risk! 4 | -------------------------------------------------------------------------------- /.nodepack/app-migration-plugin-versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "@nodepack/service": "0.0.3", 3 | "@nodepack/plugin-typescript": "0.0.2" 4 | } 5 | -------------------------------------------------------------------------------- /.nodepack/app-migration-records.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "default-template@0.0.1", 4 | "pluginId": "@nodepack/service", 5 | "pluginVersion": "0.0.3", 6 | "options": {} 7 | }, 8 | { 9 | "id": "default-template@0.0.1", 10 | "pluginId": "@nodepack/plugin-typescript", 11 | "pluginVersion": "0.0.2", 12 | "options": { 13 | "tslint": true 14 | } 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Guillaume Chau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | Logo 4 |

5 | 6 | # graphql-migrate 7 | 8 | [![circleci](https://img.shields.io/circleci/project/github/Akryum/graphql-migrate/master.svg)](https://circleci.com/gh/Akryum/graphql-migrate) 9 | 10 | Instantly create or update a SQL database from a GraphQL API schema. 11 | 12 |
13 | 14 |

Documentation

15 | 16 |

17 | 18 | Become a Patreon 19 | 20 |

21 | 22 | ## Sponsors 23 | 24 | ### Gold 25 | 26 |

27 | 28 | sum.cumo logo 29 | 30 |

31 | 32 | ### Silver 33 | 34 |

35 | 36 | VueSchool logo 37 | 38 | 39 | 40 | Vue Mastery logo 41 | 42 |

43 | 44 | ### Bronze 45 | 46 |

47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |

55 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | locales: { 3 | '/': { 4 | lang: 'en-US', 5 | title: 'GraphQL Migrate', 6 | description: '⚡ Instantly create or update a SQL database from a GraphQL API schema', 7 | }, 8 | }, 9 | serviceWorker: true, 10 | head: [ 11 | ['link', { rel: 'icon', href: '/favicon.png' }], 12 | ], 13 | themeConfig: { 14 | logo: '/logo-small.png', 15 | repo: 'Akryum/graphql-migrate', 16 | docsDir: 'docs', 17 | docsBranch: 'master', 18 | editLinks: true, 19 | sidebarDepth: 3, 20 | locales: { 21 | '/': { 22 | label: 'English', 23 | selectText: 'Languages', 24 | lastUpdated: 'Last Updated', 25 | editLinkText: 'Edit this page on GitHub', 26 | serviceWorker: { 27 | updatePopup: { 28 | message: 'New content is available.', 29 | buttonText: 'Refresh', 30 | }, 31 | }, 32 | nav: [ 33 | { 34 | text: 'Guide', 35 | link: '/guide/', 36 | }, 37 | ], 38 | sidebar: { 39 | '/guide/': [ 40 | '/guide/', 41 | '/guide/options', 42 | '/guide/name-transforms', 43 | '/guide/cookbook', 44 | '/guide/scalar-mapping', 45 | '/guide/plugins', 46 | '/guide/internal-schema', 47 | '/guide/cli', 48 | '/guide/compatibility', 49 | ], 50 | }, 51 | }, 52 | }, 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /docs/.vuepress/override.styl: -------------------------------------------------------------------------------- 1 | // Main colors 2 | $accentColor = #18c5a8 3 | $textColor = #2c3e50 4 | $borderColor = #eaecef 5 | $codeBgColor = #282c34 6 | -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akryum/graphql-migrate/3ce0fe21178da6f32d39e6d7e1a16f612a603352/docs/.vuepress/public/favicon.png -------------------------------------------------------------------------------- /docs/.vuepress/public/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akryum/graphql-migrate/3ce0fe21178da6f32d39e6d7e1a16f612a603352/docs/.vuepress/public/logo-small.png -------------------------------------------------------------------------------- /docs/.vuepress/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akryum/graphql-migrate/3ce0fe21178da6f32d39e6d7e1a16f612a603352/docs/.vuepress/public/logo.png -------------------------------------------------------------------------------- /docs/.vuepress/style.styl: -------------------------------------------------------------------------------- 1 | @import 'override' 2 | 3 | .gold-sponsor, 4 | .silver-sponsor, 5 | .bronze-sponsor 6 | margin 0 20px 7 | 8 | .gold-sponsor 9 | max-width 400px !important 10 | 11 | .silver-sponsor 12 | max-width 200px !important 13 | 14 | .bronze-sponsor 15 | max-width 100px !important 16 | max-height 50px !important 17 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | heroImage: /logo.png 4 | actionText: Get Started → 5 | actionLink: /guide/ 6 | footer: 'MIT License | Copyright © 2018-present Guillaume Chau' 7 | --- 8 | 9 |
10 |
11 |

Zero-config

12 |

Sensible defaults to get you started quickly with a production-ready database schema.

13 |
14 |
15 |

Customizable

16 |

Tailor the migrations for your own use case. Use the plugin system to extend functionnality.

17 |
18 |
19 |

Schema-first

20 |

Your GraphQL schema is the center of truth and the database is built around it.

21 |
22 |
23 | 24 | ## Sponsors 25 | 26 | ### Gold 27 | 28 |

29 | 30 | sum.cumo logo 31 | 32 |

33 | 34 | ### Silver 35 | 36 |

37 | 38 | VueSchool logo 39 | 40 | 41 | 42 | Vue Mastery logo 43 | 44 |

45 | 46 | ### Bronze 47 | 48 |

49 | 50 | Vuetify logo 51 | 52 | 53 | 54 | Frontend Developer Love logo 55 | 56 |

57 | 58 | ## Become a sponsor 59 | 60 | Is your company using `graphql-migrate`? Join the other patrons and become a sponsor to add your logo on this documentation! Supporting me on Patreon allows me to work more on Free Open Source Software! Thank you! 61 | 62 |

63 | 64 | Become a Patreon 65 | 66 |

67 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | GraphQL Migrate automatically create/update tables and columns from a GraphQL schema. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm i graphql-migrate 9 | ``` 10 | 11 | Or using yarn: 12 | 13 | ```bash 14 | yarn add graphql-migrate 15 | ``` 16 | 17 | ## Basic Usage 18 | 19 | The `migrate` methods is able to create and update tables and columns. It will execute those steps: 20 | 21 | - Read your database and construct an abstraction. 22 | - Read your GraphQL schema and turn it to an equivalent abstraction. 23 | - Compare both abstractions and generate database operations. 24 | - Convert to SQL and execute the queries from operations using [knex](https://knexjs.org). 25 | 26 | All the operations executed on the database will be wrapped in a transaction: if an error occurs, it will be fully rollbacked to the initial state. 27 | 28 | ```js 29 | import { buildSchema } from 'graphql' 30 | import { migrate } from 'graphql-migrate' 31 | 32 | const config = { 33 | client: 'pg', 34 | connection: { 35 | host: 'localhost', 36 | user: 'some-user', 37 | password: 'secret-password', 38 | database: 'my-app', 39 | }, 40 | } 41 | 42 | const schema = buildSchema(` 43 | type User { 44 | id: ID! 45 | name: String 46 | messages: [Message] 47 | contacts: [User] 48 | } 49 | 50 | type Message { 51 | id: ID! 52 | user: User 53 | } 54 | `) 55 | 56 | migrate(config, schema, { 57 | // Additional options here 58 | }).then(() => { 59 | console.log('Your database is up-to-date!') 60 | }) 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/guide/cli.md: -------------------------------------------------------------------------------- 1 | # CLI Usage 2 | 3 | ::: tip WIP 4 | Available soon! 5 | ::: 6 | -------------------------------------------------------------------------------- /docs/guide/compatibility.md: -------------------------------------------------------------------------------- 1 | # Database compatibility 2 | 3 | **🙂 Contributions are welcome for SQL queries or testing!** 4 | 5 | | Icon | Meaning | 6 | |:--:| ----- | 7 | | ✔️ | Supported | 8 | | ❓ | Not tested | 9 | | - | Not implemented | 10 | | ❌ | Not supported | 11 | 12 | --- 13 | 14 | | Operation | pg | mysql | mssql | oracle | sqlite3 | 15 | | ------------- |:--:|:-----:|:-----:|:------:|:-------:| 16 | | Read tables | ✔️ | ❓ | ❓ | ❓ | ❓ | 17 | | Read table comments | ✔️ | ❓ | - | - | ❌ | 18 | | Read columns | ✔️ | ❓ | ❓ | ❓ | ❓ | 19 | | Read column types | ✔️ | ❓ | ❓ | ❓ | ❓ | 20 | | Read column comments | ✔️ | - | - | - | ❌ | 21 | | Read column default values | ✔️ | ❓ | ❓ | ❓ | ❓ | 22 | | Read foreign keys | ✔️ | - | - | - | - | 23 | | Read primary keys | ✔️ | - | - | - | - | 24 | | Read index | ✔️ | - | - | - | - | 25 | | Read unique constraint | ✔️ | - | - | - | - | 26 | | Write tables | ✔️ | ❓ | ❓ | ❓ | ❓ | 27 | | Write table comments | ✔️ | ❓ | ❓ | ❓ | ❌ | 28 | | Write columns | ✔️ | ❓ | ❓ | ❓ | ❓ | 29 | | Write column comments | ✔️ | ❓ | ❓ | ❓ | ❌ | 30 | | Write foreign keys | ✔️ | ❓ | ❓ | ❓ | ❓ | 31 | | Write primary keys | ✔️ | ❓ | ❓ | ❓ | ❓ | 32 | | Write index | ✔️ | ❓ | ❓ | ❓ | ❓ | 33 | | Write unique constraint | ✔️ | ❓ | ❓ | ❓ | ❓ | 34 | -------------------------------------------------------------------------------- /docs/guide/cookbook.md: -------------------------------------------------------------------------------- 1 | # Cookbook 2 | 3 | Schema annotations are parsed using [graphql-annotations](https://github.com/Akryum/graphql-annotations). 4 | 5 | ## Simple type with comments 6 | 7 | ```graphql 8 | """ 9 | A user. 10 | """ 11 | type User { 12 | id: ID! 13 | 14 | """ 15 | Display name. 16 | """ 17 | name: String! 18 | } 19 | ``` 20 | 21 | ## Skip table or field 22 | 23 | ```graphql 24 | """ 25 | @db.skip 26 | """ 27 | type MutationResult { 28 | success: Boolean! 29 | } 30 | 31 | type OtherType { 32 | id: ID! 33 | """ 34 | @db.skip 35 | """ 36 | computedField: String 37 | } 38 | ``` 39 | 40 | ## Rename 41 | 42 | ```graphql 43 | """ 44 | @db.oldNames: ['user'] 45 | """ 46 | type People { 47 | id: ID! 48 | 49 | """ 50 | @db.oldNames: ['name'] 51 | """ 52 | nickname: String! 53 | } 54 | ``` 55 | 56 | ## Not null field 57 | 58 | ```graphql 59 | type User { 60 | """ 61 | Not null 62 | """ 63 | name: String! 64 | 65 | """ 66 | Nullable 67 | """ 68 | nickname: String 69 | } 70 | ``` 71 | 72 | ## Default field value 73 | 74 | ```graphql 75 | type User { 76 | """ 77 | @db.default: true 78 | """ 79 | someOption: Boolean 80 | } 81 | ``` 82 | 83 | ## Default primary index 84 | 85 | By default, `id` fields of type `ID` will be the primary key on the table: 86 | 87 | ```graphql 88 | type User { 89 | """ 90 | This will get a primary index 91 | """ 92 | id: ID! 93 | email: String! 94 | } 95 | ``` 96 | 97 | In this example, no primary key will be generated automatically: 98 | 99 | ```graphql 100 | type User { 101 | """ 102 | This will NOT get a primary index 103 | """ 104 | foo: ID! 105 | 106 | """ 107 | Neither will this 108 | """ 109 | id: String! 110 | } 111 | ``` 112 | 113 | You can disable the automatic primary key: 114 | 115 | ```graphql 116 | type User { 117 | """ 118 | @db.primary: false 119 | """ 120 | id: ID! 121 | 122 | email: String! 123 | } 124 | ``` 125 | 126 | ## Primary key 127 | 128 | In this example, the primary key will be on `email` instead of `id`: 129 | 130 | ```graphql 131 | type User { 132 | id: ID! 133 | 134 | """ 135 | @db.primary 136 | """ 137 | email: String! 138 | } 139 | ``` 140 | 141 | ## Simple index 142 | 143 | ```graphql 144 | type User { 145 | id: ID! 146 | 147 | """ 148 | @db.index 149 | """ 150 | email: String! 151 | } 152 | ``` 153 | 154 | ## Multiple index 155 | 156 | ```graphql 157 | type User { 158 | """ 159 | @db.index 160 | """ 161 | id: String! 162 | 163 | """ 164 | @db.index 165 | """ 166 | email: String! 167 | } 168 | ``` 169 | 170 | ## Named index 171 | 172 | ```graphql 173 | type User { 174 | """ 175 | @db.index: 'myIndex' 176 | """ 177 | email: String! 178 | 179 | """ 180 | @db.index: 'myIndex' 181 | """ 182 | name: String! 183 | } 184 | ``` 185 | 186 | You can also specify an index type on PostgresSQL or MySQL: 187 | 188 | ```graphql 189 | type User { 190 | """ 191 | @db.index: { name: 'myIndex', type: 'hash' } 192 | """ 193 | email: String! 194 | 195 | """ 196 | You don't need to specify the type again. 197 | @db.index: 'myIndex' 198 | """ 199 | name: String! 200 | } 201 | ``` 202 | 203 | ## Unique constraint 204 | 205 | ```graphql 206 | type User { 207 | id: ID! 208 | """ 209 | @db.unique 210 | """ 211 | email: String! 212 | } 213 | ``` 214 | 215 | ## Custom name 216 | 217 | ```graphql 218 | """ 219 | @db.name: 'people' 220 | """ 221 | type User { 222 | """ 223 | @db.name: 'uuid' 224 | """ 225 | id: ID! 226 | } 227 | ``` 228 | 229 | ## Custom column type 230 | 231 | ```graphql 232 | type User { 233 | """ 234 | @db.type: 'string' 235 | @db.length: 36 236 | """ 237 | id: ID! 238 | } 239 | ``` 240 | 241 | See [knex schema builder methods](https://knexjs.org/#Schema-increments) for the supported types. 242 | 243 | ## Simple list 244 | 245 | ```graphql 246 | type User { 247 | id: ID! 248 | 249 | """ 250 | @db.type: 'json' 251 | """ 252 | names: [String] 253 | } 254 | ``` 255 | 256 | You can set the `mapListToJson` option to automatically map scalar and enum lists to JSON: 257 | 258 | ```js 259 | const schema = buildSchema(` 260 | type User { 261 | names: [String] 262 | } 263 | `) 264 | const adb = await generateAbstractDatabase(schema, { 265 | mapListToJson: true, 266 | }) 267 | ``` 268 | 269 | ## Foreign key 270 | 271 | ```graphql 272 | type User { 273 | id: ID! 274 | messages: [Message] 275 | } 276 | 277 | type Message { 278 | id: ID! 279 | user: User 280 | } 281 | ``` 282 | 283 | This will create the following tables: 284 | 285 | ```js 286 | { 287 | user: { 288 | id: uuid primary 289 | }, 290 | message: { 291 | id: uuid primary 292 | user_foreign: uuid foreign key references 'user.id' 293 | } 294 | } 295 | ``` 296 | 297 | ## Many-to-many 298 | 299 | ```graphql 300 | type User { 301 | id: ID! 302 | """ 303 | @db.manyToMany: 'users' 304 | """ 305 | messages: [Message] 306 | } 307 | 308 | type Message { 309 | id: ID! 310 | """ 311 | @db.manyToMany: 'messages' 312 | """ 313 | users: [User] 314 | } 315 | ``` 316 | 317 | This will create an additional join table: 318 | 319 | ```js 320 | { 321 | message_users_join_user_messages: { 322 | users_foreign: uuid foreign key references 'message.id', 323 | messages_foreign: uuid foreign key references 'user.id', 324 | } 325 | } 326 | ``` 327 | 328 | ## Many-to-many on same type 329 | 330 | ```graphql 331 | type User { 332 | id: ID! 333 | contacts: [User] 334 | } 335 | ``` 336 | 337 | This will create an additional join table: 338 | 339 | ```js 340 | { 341 | user_contacts_join_user_contacts: { 342 | id_foreign: uuid foreign key references 'user.id', 343 | id_foreign_other: uuid foreign key references 'user.id', 344 | } 345 | } 346 | ``` 347 | -------------------------------------------------------------------------------- /docs/guide/internal-schema.md: -------------------------------------------------------------------------------- 1 | # Internal types and fields 2 | 3 | You may want to have internal types and fields that shouldn't be exposed on the GraphQL API. To accomplish this, you can make two different GraphQL schemas: 4 | 5 | - The first one only have the public types and fields. 6 | - The second one also have the internal types and fields and will only be used to migrate the database. 7 | 8 | Here is an example with Apollo Server: 9 | 10 | ```js 11 | import { makeExecutableSchema } from 'apollo-server' 12 | 13 | const publicTypeDefs = [ 14 | gql` 15 | type User { 16 | id: ID! 17 | email: String! 18 | } 19 | 20 | type Query { 21 | user (id: ID!): User 22 | } 23 | `, 24 | ] 25 | 26 | const resolvers = { 27 | Query: { 28 | user: () => { /* ... */ }, 29 | }, 30 | } 31 | 32 | // Not exposed in final API 33 | const internalTypeDefs = [ 34 | gql` 35 | extend type User { 36 | encryptedPassword: String! 37 | } 38 | `, 39 | ] 40 | 41 | // For Database only 42 | const dbSchema = makeExecutableSchema({ 43 | typeDefs: publicTypeDefs.concat(...internalTypeDefs), 44 | }) 45 | 46 | const ops = await migrate(knexConfig, dbSchema) 47 | console.log(`Migrated DB (${ops.length} ops).`) 48 | 49 | // For Apollo server 50 | const schema = makeExecutableSchema({ 51 | typeDefs: publicTypeDefs, 52 | resolvers, 53 | }) 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/guide/name-transforms.md: -------------------------------------------------------------------------------- 1 | # Name transforms 2 | 3 | You can customize the way table and column names are transformed before being applied to the database with the `transformTableName` and `transformColumnName` options. 4 | 5 | By default, they will convert the table and column names to Snake case: 6 | 7 | ```js 8 | import Case from 'case' 9 | 10 | migrate(nkexConfig, schema, { 11 | transformTableName: (name, direction) => { 12 | if (direction === 'to-db') { 13 | return Case.snake(name) 14 | } 15 | return name 16 | }, 17 | transformColumnName: (name, direction) => { 18 | if (direction === 'to-db') { 19 | return Case.snake(name) 20 | } 21 | return name 22 | }, 23 | }) 24 | ``` 25 | 26 | For example, let's consider this schema: 27 | 28 | ```graphql 29 | type UserTeam { 30 | id: ID! 31 | name: String! 32 | yearlyBilling: Boolean! 33 | } 34 | ``` 35 | 36 | We will create a `user_team` table with those columns: 37 | 38 | - `id` 39 | - `name` 40 | - `yearly_billing` 41 | 42 | ## Usage with Knex 43 | 44 | Since by default table and columns names will be transformed to `snake_case`, you may want to automatically convert the names when using knex for your GraphQL API. 45 | 46 | Here is some knex configuration you'll need to do: 47 | 48 | ```js 49 | import Case from 'case' 50 | 51 | export const knex = Knex({ 52 | /* Knex config here ... */ 53 | 54 | // Convert identifiers to snake_case 55 | wrapIdentifier: (value, origImpl, queryContext) => origImpl(Case.snake(value)), 56 | 57 | // Convert column names back to camelCase 58 | postProcessResponse: (result, queryContext) => { 59 | if (Array.isArray(result)) { 60 | return result.map((row: any) => convertColumns(row)) 61 | } else { 62 | return convertColumns(result) 63 | } 64 | }, 65 | }) 66 | 67 | function convertColumns (row: any) { 68 | const result: any = {} 69 | for (const key of Object.keys(row)) { 70 | result[Case.camel(key)] = row[key] 71 | } 72 | return result 73 | } 74 | ``` 75 | 76 | ::: warning 77 | Don't apply those to the same Knex configuration object as `migrate`. 78 | ::: 79 | 80 | Considering the example schema we had in the previous section, here is an example query that inserts a `UserTeam` object to the DB: 81 | 82 | ```js 83 | await knex.table('UserTeam').insert({ 84 | id: '34211bef-6815-4b49-958a-f89b24449958', 85 | name: 'Acme', 86 | yearlyBilling: false, 87 | }) 88 | ``` 89 | 90 | Knex will then execute the following SQL query: 91 | 92 | ```sql 93 | INSERT INTO 'user_team' ('id', 'name', 'yearly_billing') 94 | VALUES ('34211bef-6815-4b49-958a-f89b24449958', 'Acme', false); 95 | ``` 96 | 97 | Then we can query the data like this: 98 | 99 | ```js 100 | await knex.table('UserTeam') 101 | .where('id', '34211bef-6815-4b49-958a-f89b24449958') 102 | .first() 103 | ``` 104 | 105 | Which will return: 106 | 107 | ```json 108 | { 109 | "id": "34211bef-6815-4b49-958a-f89b24449958", 110 | "name": "Acme", 111 | "yearlyBilling": false 112 | } 113 | ``` 114 | -------------------------------------------------------------------------------- /docs/guide/options.md: -------------------------------------------------------------------------------- 1 | # Migrate Options 2 | 3 | `migrate` has the following arguments: 4 | 5 | - `config`: a [knex config object](https://knexjs.org/#Installation-client) to connect to the database. 6 | - `schema`: a GraphQL schema object. You can use `buildSchema` from `graphql`. 7 | - `options`: 8 | - `dbSchemaName` (default: `'public'`): table schema: `.`. 9 | - `dbTablePrefix` (default: `''`): table name prefix: ``. 10 | - `dbColumnPrefix` (default: `''`): column name prefix: ``. 11 | - `updateComments` (default: `false`): by default, `migrate` won't overwrite comments on table and columns. This forces comment overwritting. 12 | - `transformTableName` (default: transform to `snake_case`): transform function for table names. 13 | - `transformColumnName` (default: transform to `snake_case`): transform function for column names. 14 | - `scalarMap` (default: `null`): Custom Scalar mapping 15 | - `mapListToJson` (default: `true`): Map scalar/enum lists to json column type by default. 16 | - `plugins` (default: `[]`): List of graphql-migrate plugins 17 | - `debug` (default: `false`): displays debugging informations and SQL queries. -------------------------------------------------------------------------------- /docs/guide/plugins.md: -------------------------------------------------------------------------------- 1 | # Custom logic with Plugins 2 | 3 | It's possible to write custom queries to be executed during migrations using Plugins. 4 | 5 | Currently a plugin can only declare tap on the Writer system, with the `write` and `tap` methods: 6 | 7 | ```js 8 | import { MigratePlugin } from 'graphql-migrate' 9 | 10 | class MyPlugin extends MigratePlugin { 11 | write ({ tap }) { 12 | tap('op-type', 'before', (op, transaction) => { 13 | // or 'after' 14 | }) 15 | } 16 | } 17 | ``` 18 | 19 | The arguments are: 20 | 21 | - `operation: string`, can be one of the following: 22 | - `table.create` 23 | - `table.rename` 24 | - `table.comment.set` 25 | - `table.drop` 26 | - `table.index.create` 27 | - `table.index.drop` 28 | - `table.primary.set` 29 | - `table.unique.create` 30 | - `table.unique.drop` 31 | - `table.foreign.create` 32 | - `table.foreign.drop` 33 | - `column.create` 34 | - `column.rename` 35 | - `column.alter` 36 | - `column.drop` 37 | - `type: 'before' | 'after'` 38 | - `callback: function` which get those parameters: 39 | - `operation`: the operation object (see [Operation.d.ts](./src/diff/Operation.d.ts)) 40 | - `transaction`: the Knex SQL transaction 41 | 42 | Then, instanciate the plugin in the `plugins` option array of the `migrate` method. 43 | 44 | For example, let's say we have the following schema: 45 | 46 | ```js 47 | // old schema 48 | const schema = buildSchema(` 49 | type User { 50 | id: ID! 51 | fname: String 52 | lname: String 53 | } 54 | `) 55 | ``` 56 | 57 | Now we want to migrate the `user` table from two columns `fname` and `lname` into one: 58 | 59 | ```js 60 | fullname = fname + ' ' + lname 61 | ``` 62 | 63 | Here is the example code to achieve this: 64 | 65 | ```js 66 | import { buildSchema } from 'graphql' 67 | import { migrate, MigratePlugin } from 'graphql-migrate' 68 | 69 | const schema = buildSchema(` 70 | type User { 71 | id: ID! 72 | """ 73 | @db.oldNames: ['lname'] 74 | """ 75 | fullname: String 76 | } 77 | `) 78 | 79 | class MyPlugin extends MigratePlugin { 80 | write ({ tap }) { 81 | tap('column.drop', 'before', async (op, transaction) => { 82 | // Check the table and column 83 | if (op.table === 'user' && op.column === 'fname') { 84 | // Update the users lname with fname + ' ' + lname 85 | const users = await transaction 86 | .select('id', 'fname', 'lname') 87 | .from('user') 88 | for (const user of users) { 89 | await transaction('user') 90 | .where({ id: user.id }) 91 | .update({ 92 | lname: `${user.fname} ${user.lname}`, 93 | }) 94 | } 95 | } 96 | }) 97 | } 98 | } 99 | 100 | migrate(config, schema, { 101 | plugins: [ 102 | new MyPlugin(), 103 | ], 104 | }) 105 | ``` 106 | 107 | Let's describe what's going on -- we: 108 | 109 | - Remove the `fname` field from the schema. 110 | - Rename `lname` to `fullname` in the schema. 111 | - Annotate the `fullname` field to indicate it's the new name of `lname`. 112 | - We declare a plugin that tap into the `column.drop` write operation. 113 | - In this hook, we read the users and update each one of them to merge the two columns into `lname` before the `fname` column is dropped. 114 | -------------------------------------------------------------------------------- /docs/guide/scalar-mapping.md: -------------------------------------------------------------------------------- 1 | # Custom Scalar Mapping 2 | 3 | To customize the scalar mapping, you can provide a function on the `scalarMap` option that gets field information and that returns a `TableColumnDescriptor` or `null`. Here is its signature: 4 | 5 | ```ts 6 | type ScalarMap = ( 7 | field: GraphQLField, 8 | scalarType: GraphQLScalarType | null, 9 | annotations: any, 10 | ) => TableColumnTypeDescriptor | null 11 | ``` 12 | 13 | Here is the `TableColumnTypeDescriptor` interface: 14 | 15 | ```ts 16 | interface TableColumnTypeDescriptor { 17 | /** 18 | * Knex column builder function name. 19 | */ 20 | type: string 21 | /** 22 | * Builder function arguments. 23 | */ 24 | args: any[] 25 | } 26 | ``` 27 | 28 | Example: 29 | 30 | ```js 31 | migrate(config, schema, { 32 | scalarMap: (field, scalarType, annotations) => { 33 | if (scalarType && scalarType.name === 'Timestamp') { 34 | return { 35 | type: 'timestamp', 36 | // useTz, precision 37 | args: [true, undefined], 38 | } 39 | } 40 | 41 | if (field.name === 'id' || annotations.type === 'uuid') { 42 | return { 43 | type: 'uuid', 44 | args: [], 45 | } 46 | } 47 | 48 | return null 49 | } 50 | }) 51 | ``` -------------------------------------------------------------------------------- /graphql-migrate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akryum/graphql-migrate/3ce0fe21178da6f32d39e6d7e1a16f612a603352/graphql-migrate.png -------------------------------------------------------------------------------- /graphql-migrate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 32 | 37 | 38 | 46 | 51 | 52 | 53 | 86 | 90 | 91 | 93 | 94 | 96 | image/svg+xml 97 | 99 | 100 | 101 | 102 | 103 | 108 | 115 | 122 | 129 | 136 | 143 | 150 | 157 | 164 | 171 | 178 | 185 | 192 | 199 | 206 | 213 | 220 | 227 | 234 | 242 | 250 | 257 | 264 | 271 | 278 | 285 | 292 | 299 | 304 | 305 | 306 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['/tests/specs/**/*.ts'], 3 | moduleFileExtensions: [ 4 | 'ts', 5 | 'js', 6 | 'json', 7 | ], 8 | transform: { 9 | '^.+\\.ts$': 'ts-jest', 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /nodepack.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@nodepack/service').ProjectOptions} */ 2 | module.exports = { 3 | // Configure your project here 4 | externals: true, 5 | productionSourceMap: true, 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-migrate", 3 | "version": "0.2.1", 4 | "description": "Create & migrate Databases from GraphQL Schema", 5 | "author": "Guillaume Chau ", 6 | "scripts": { 7 | "dev": "nodepack-service build --watch", 8 | "build": "nodepack-service build", 9 | "tslint": "nodepack-service lint", 10 | "eslint": "eslint {src,tests}/**/*.ts", 11 | "start": "node ./dist/app.js", 12 | "test:types": "tsc --noEmit", 13 | "test:unit": "jest", 14 | "test": "yarn run tslint && yarn run eslint && yarn run test:types && yarn run test:unit", 15 | "prepublishOnly": "yarn run test && yarn run build", 16 | "docs:dev": "vuepress dev docs", 17 | "docs:build": "vuepress build docs" 18 | }, 19 | "dependencies": { 20 | "case": "^1.6.1", 21 | "graphql-annotations": "^0.0.3", 22 | "knex": "^0.16.3", 23 | "lodash.isequal": "^4.5.0" 24 | }, 25 | "devDependencies": { 26 | "@nodepack/plugin-typescript": "^0.1.14", 27 | "@nodepack/service": "^0.1.14", 28 | "@types/graphql": "^14.0.5", 29 | "@types/jest": "^24.0.11", 30 | "@typescript-eslint/eslint-plugin": "^1.1.1", 31 | "@typescript-eslint/parser": "^1.1.1", 32 | "eslint": "^5.12.1", 33 | "eslint-config-standard": "^12.0.0", 34 | "eslint-plugin-import": "^2.16.0", 35 | "eslint-plugin-node": "^8.0.1", 36 | "eslint-plugin-promise": "^4.0.1", 37 | "eslint-plugin-standard": "^4.0.0", 38 | "graphql": "^14.1.1", 39 | "graphql-tools": "^4.0.4", 40 | "jest": "^24.0.0", 41 | "pg": "^7.8.0", 42 | "ts-jest": "^24.0.0", 43 | "typescript": "^3.2.2", 44 | "vuepress": "^0.14.10" 45 | }, 46 | "peerDependencies": { 47 | "graphql": "^14.1.1" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/Akryum/graphql-migrate/issues" 51 | }, 52 | "homepage": "https://github.com/Akryum/graphql-migrate#readme", 53 | "license": "MIT", 54 | "main": "dist/app.js", 55 | "types": "src/index.ts", 56 | "repository": { 57 | "type": "git", 58 | "url": "git+https://github.com/Akryum/graphql-migrate.git" 59 | }, 60 | "files": [ 61 | "dist", 62 | "src", 63 | "LICENSE" 64 | ], 65 | "resolutions": { 66 | "webpack-dev-middleware": "3.6.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/abstract/AbstractDatabase.ts: -------------------------------------------------------------------------------- 1 | import { Table } from './Table' 2 | 3 | export interface AbstractDatabase { 4 | tables: Table[] 5 | tableMap: Map 6 | } 7 | -------------------------------------------------------------------------------- /src/abstract/Table.ts: -------------------------------------------------------------------------------- 1 | import { TableColumn } from './TableColumn' 2 | 3 | export interface Table { 4 | name: string 5 | comment: string | null 6 | annotations: any 7 | columns: TableColumn[] 8 | columnMap: Map 9 | indexes: TableIndex[] 10 | primaries: TablePrimary[] 11 | uniques: TableUnique[] 12 | } 13 | 14 | export interface TableIndex { 15 | columns: string[] 16 | name: string | null 17 | type: string | null 18 | } 19 | 20 | export interface TablePrimary { 21 | columns: string[] 22 | name: string | null 23 | } 24 | 25 | export interface TableUnique { 26 | columns: string[] 27 | name: string | null 28 | } 29 | -------------------------------------------------------------------------------- /src/abstract/TableColumn.ts: -------------------------------------------------------------------------------- 1 | export type TableColumnType = 2 | 'integer' | 3 | 'bigInteger' | 4 | 'text' | 5 | 'string' | 6 | 'float' | 7 | 'decimal' | 8 | 'boolean' | 9 | 'date' | 10 | 'datetime' | 11 | 'time' | 12 | 'timestamp' | 13 | 'binary' | 14 | 'enum' | 15 | 'json' | 16 | 'jsonb' | 17 | 'uuid' 18 | 19 | export interface ForeignKey { 20 | type: string | null 21 | field: string | null 22 | tableName: string | null 23 | columnName: string | null 24 | } 25 | 26 | export interface TableColumn { 27 | name: string 28 | comment: string | null 29 | annotations: any 30 | type: string 31 | args: any[] 32 | nullable: boolean 33 | foreign: ForeignKey | null 34 | defaultValue: any 35 | } 36 | -------------------------------------------------------------------------------- /src/abstract/generateAbstractDatabase.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLSchema, 3 | GraphQLObjectType, 4 | GraphQLField, 5 | GraphQLOutputType, 6 | isObjectType, 7 | isScalarType, 8 | isEnumType, 9 | isListType, 10 | isNonNullType, 11 | GraphQLScalarType, 12 | } from 'graphql' 13 | import { TypeMap } from 'graphql/type/schema' 14 | import { AbstractDatabase } from './AbstractDatabase' 15 | import { Table } from './Table' 16 | import { TableColumn, ForeignKey } from './TableColumn' 17 | import { parseAnnotations, stripAnnotations } from 'graphql-annotations' 18 | import getColumnTypeFromScalar, { TableColumnTypeDescriptor } from './getColumnTypeFromScalar' 19 | import { escapeComment } from '../util/comments' 20 | import { defaultNameTransform } from '../util/defaultNameTransforms' 21 | 22 | const ROOT_TYPES = ['Query', 'Mutation', 'Subscription'] 23 | 24 | const INDEX_TYPES = [ 25 | { 26 | annotation: 'index', 27 | list: 'indexes', 28 | hasType: true, 29 | defaultName: (table: string, column: string) => `${table}_${column}_index`, 30 | }, 31 | { 32 | annotation: 'primary', 33 | list: 'primaries', 34 | default: (name: string, type: string) => name === 'id' && type === 'ID', 35 | max: 1, 36 | defaultName: (table: string) => `${table}_pkey`, 37 | }, 38 | { 39 | annotation: 'unique', 40 | list: 'uniques', 41 | defaultName: (table: string, column: string) => `${table}_${column}_unique`, 42 | }, 43 | ] 44 | 45 | export type ScalarMap = ( 46 | field: GraphQLField, 47 | scalarType: GraphQLScalarType | null, 48 | annotations: any, 49 | ) => TableColumnTypeDescriptor | null 50 | 51 | export type NameTransformDirection = 'from-db' | 'to-db' 52 | 53 | export type NameTransform = ( 54 | name: string, 55 | direction: NameTransformDirection, 56 | ) => string 57 | 58 | export interface GenerateAbstractDatabaseOptions { 59 | scalarMap?: ScalarMap | null 60 | mapListToJson?: boolean 61 | transformTableName?: NameTransform | null 62 | transformColumnName?: NameTransform | null 63 | } 64 | 65 | export const defaultOptions: GenerateAbstractDatabaseOptions = { 66 | scalarMap: null, 67 | transformTableName: defaultNameTransform, 68 | transformColumnName: defaultNameTransform, 69 | } 70 | 71 | export async function generateAbstractDatabase ( 72 | schema: GraphQLSchema, 73 | options: GenerateAbstractDatabaseOptions = defaultOptions, 74 | ): Promise { 75 | const builder = new AbstractDatabaseBuilder(schema, options) 76 | return builder.build() 77 | } 78 | 79 | class AbstractDatabaseBuilder { 80 | private schema: GraphQLSchema 81 | private scalarMap: ScalarMap | null 82 | private mapListToJson: boolean 83 | private transformTableName: NameTransform | null 84 | private transformColumnName: NameTransform | null 85 | private typeMap: TypeMap 86 | private database: AbstractDatabase 87 | /** Used to push new intermediary tables after current table */ 88 | private tableQueue: Table[] = [] 89 | private currentTable: Table | null = null 90 | private currentType: string | null = null 91 | 92 | constructor (schema: GraphQLSchema, options: GenerateAbstractDatabaseOptions) { 93 | this.schema = schema 94 | this.transformTableName = options.transformTableName 95 | this.transformColumnName = options.transformColumnName 96 | this.scalarMap = options.scalarMap as ScalarMap | null 97 | this.mapListToJson = options.mapListToJson || defaultOptions.mapListToJson as boolean 98 | this.typeMap = this.schema.getTypeMap() 99 | 100 | this.database = { 101 | tables: [], 102 | tableMap: new Map(), 103 | } 104 | } 105 | 106 | public build (): AbstractDatabase { 107 | for (const key in this.typeMap) { 108 | if (this.typeMap[key]) { 109 | const type = this.typeMap[key] 110 | // Tables 111 | if (isObjectType(type) && !type.name.startsWith('__') && !ROOT_TYPES.includes(type.name)) { 112 | this.buildTable(type) 113 | } 114 | } 115 | } 116 | this.database.tables.push(...this.tableQueue) 117 | this.fillForeignKeys() 118 | return this.database 119 | } 120 | 121 | private getTableName (name: string) { 122 | if (this.transformTableName) { 123 | return this.transformTableName(name, 'to-db') 124 | } 125 | return name 126 | } 127 | 128 | private getColumnName (name: string) { 129 | if (this.transformColumnName) { 130 | return this.transformColumnName(name, 'to-db') 131 | } 132 | return name 133 | } 134 | 135 | private buildTable (type: GraphQLObjectType) { 136 | const annotations: any = parseAnnotations('db', type.description || null) 137 | 138 | if (annotations.skip) { 139 | return 140 | } 141 | 142 | const table: Table = { 143 | name: annotations.name || this.getTableName(type.name), 144 | comment: escapeComment(stripAnnotations(type.description || null)), 145 | annotations, 146 | columns: [], 147 | columnMap: new Map(), 148 | indexes: [], 149 | primaries: [], 150 | uniques: [], 151 | } 152 | 153 | this.currentTable = table 154 | this.currentType = type.name 155 | 156 | const fields = type.getFields() 157 | for (const key in fields) { 158 | if (fields[key]) { 159 | const field = fields[key] 160 | this.buildColumn(table, field) 161 | } 162 | } 163 | 164 | this.currentTable = null 165 | this.currentType = null 166 | 167 | this.database.tables.push(table) 168 | this.database.tableMap.set(type.name, table) 169 | 170 | return table 171 | } 172 | 173 | private buildColumn (table: Table, field: GraphQLField) { 174 | const descriptor = this.getFieldDescriptor(field) 175 | if (!descriptor) { return } 176 | table.columns.push(descriptor) 177 | table.columnMap.set(field.name, descriptor) 178 | return descriptor 179 | } 180 | 181 | private getFieldDescriptor ( 182 | field: GraphQLField, 183 | fieldType: GraphQLOutputType | null = null, 184 | ): TableColumn | null { 185 | const annotations: any = parseAnnotations('db', field.description || null) 186 | 187 | if (annotations.skip) { 188 | return null 189 | } 190 | 191 | if (!fieldType) { 192 | fieldType = isNonNullType(field.type) ? field.type.ofType : field.type 193 | } 194 | 195 | const notNull = isNonNullType(field.type) 196 | let columnName: string = annotations.name || this.getColumnName(field.name) 197 | let type: string 198 | let args: any[] 199 | let foreign: ForeignKey | null = null 200 | 201 | // Scalar 202 | if (isScalarType(fieldType) || annotations.type) { 203 | let descriptor 204 | if (this.scalarMap) { 205 | descriptor = this.scalarMap(field, isScalarType(fieldType) ? fieldType : null, annotations) 206 | } 207 | if (!descriptor) { 208 | descriptor = getColumnTypeFromScalar(field, isScalarType(fieldType) ? fieldType : null, annotations) 209 | } 210 | if (!descriptor) { 211 | console.warn(`Unsupported type ${fieldType} on field ${this.currentType}.${field.name}.`) 212 | return null 213 | } 214 | type = descriptor.type 215 | args = descriptor.args 216 | 217 | // Enum 218 | } else if (isEnumType(fieldType)) { 219 | type = 'enum' 220 | args = [fieldType.getValues().map((v) => v.name)] 221 | 222 | // Object 223 | } else if (isObjectType(fieldType)) { 224 | columnName = annotations.name || this.getColumnName(`${field.name}_foreign`) 225 | const foreignType = this.typeMap[fieldType.name] 226 | if (!foreignType) { 227 | console.warn(`Foreign type ${fieldType.name} not found on field ${this.currentType}.${field.name}.`) 228 | return null 229 | } 230 | if (!isObjectType(foreignType)) { 231 | console.warn(`Foreign type ${fieldType.name} is not Object type on field ${this.currentType}.${field.name}.`) 232 | return null 233 | } 234 | const foreignKey: string = annotations.foreign || 'id' 235 | const foreignField = foreignType.getFields()[foreignKey] 236 | if (!foreignField) { 237 | console.warn(`Foreign field ${foreignKey} on type ${fieldType.name} not found on field ${field.name}.`) 238 | return null 239 | } 240 | const descriptor = this.getFieldDescriptor(foreignField) 241 | if (!descriptor) { 242 | // tslint:disable-next-line max-line-length 243 | console.warn(`Couldn't create foreign field ${foreignKey} on type ${fieldType.name} on field ${field.name}. See above messages.`) 244 | return null 245 | } 246 | type = descriptor.type 247 | args = descriptor.args 248 | foreign = { 249 | type: foreignType.name, 250 | field: foreignField.name, 251 | tableName: null, 252 | columnName: null, 253 | } 254 | 255 | // List 256 | } else if (isListType(fieldType) && this.currentTable) { 257 | let ofType = fieldType.ofType 258 | ofType = isNonNullType(ofType) ? ofType.ofType : ofType 259 | if (isObjectType(ofType)) { 260 | // Foreign Type 261 | const onSameType = this.currentType === ofType.name 262 | const foreignType = this.typeMap[ofType.name] 263 | if (!foreignType) { 264 | console.warn(`Foreign type ${ofType.name} not found on field ${this.currentType}.${field.name}.`) 265 | return null 266 | } 267 | if (!isObjectType(foreignType)) { 268 | console.warn(`Foreign type ${ofType.name} is not Object type on field ${this.currentType}.${field.name}.`) 269 | return null 270 | } 271 | 272 | // Foreign Field 273 | const foreignKey = onSameType ? field.name : annotations.manyToMany || this.currentTable.name 274 | const foreignField = foreignType.getFields()[foreignKey] 275 | if (!foreignField) { return null } 276 | // @db.foreign 277 | const foreignAnnotations: any = parseAnnotations('db', foreignField.description || null) 278 | const foreignAnnotation = foreignAnnotations.foreign 279 | if (foreignAnnotation && foreignAnnotation !== field.name) { return null } 280 | // Type 281 | const foreignFieldType = isNonNullType(foreignField.type) ? foreignField.type.ofType : foreignField.type 282 | if (!isListType(foreignFieldType)) { return null } 283 | 284 | // Create join table for many-to-many 285 | const tableName = this.getTableName([ 286 | `${this.currentType}_${field.name}`, 287 | `${foreignType.name}_${foreignField.name}`, 288 | ].sort().join('_join_')) 289 | let joinTable = this.database.tableMap.get(tableName) || null 290 | if (!joinTable) { 291 | joinTable = { 292 | name: tableName, 293 | comment: escapeComment(annotations.tableComment) || 294 | // tslint:disable-next-line max-line-length 295 | `[Auto] Join table between ${this.currentType}.${field.name} and ${foreignType.name}.${foreignField.name}`, 296 | annotations: {}, 297 | columns: [], 298 | columnMap: new Map(), 299 | indexes: [], 300 | primaries: [], 301 | uniques: [], 302 | } 303 | this.tableQueue.push(joinTable) 304 | this.database.tableMap.set(tableName, joinTable) 305 | } 306 | let descriptors = [] 307 | if (onSameType) { 308 | const key = annotations.manyToMany || 'id' 309 | const sameTypeForeignField = foreignType.getFields()[key] 310 | if (!sameTypeForeignField) { 311 | // tslint:disable-next-line max-line-length 312 | console.warn(`Foreign field ${key} on type ${ofType.name} not found on field ${this.currentType}.${field.name}.`) 313 | return null 314 | } 315 | const descriptor = this.getFieldDescriptor(sameTypeForeignField, ofType) 316 | if (!descriptor) { return null } 317 | descriptors = [ 318 | descriptor, 319 | { 320 | ...descriptor, 321 | }, 322 | ] 323 | } else { 324 | const descriptor = this.getFieldDescriptor(foreignField, ofType) 325 | if (!descriptor) { return null } 326 | descriptors = [descriptor] 327 | } 328 | for (const descriptor of descriptors) { 329 | if (joinTable.columnMap.get(descriptor.name)) { 330 | descriptor.name += '_other' 331 | } 332 | joinTable.columns.push(descriptor) 333 | joinTable.columnMap.set(descriptor.name, descriptor) 334 | } 335 | // Index 336 | joinTable.indexes.push({ 337 | columns: descriptors.map((d) => d.name), 338 | name: `${joinTable.name}_${descriptors.map((d) => d.name).join('_')}_index`.substr(0, 63), 339 | type: null, 340 | }) 341 | return null 342 | } else if (this.mapListToJson) { 343 | type = 'json' 344 | args = [] 345 | } else { 346 | console.warn(`Unsupported Scalar/Enum list on field ${this.currentType}.${field.name}. Use @db.type: "json"`) 347 | return null 348 | } 349 | // Unsupported 350 | } else { 351 | // tslint:disable-next-line max-line-length 352 | console.warn(`Field ${this.currentType}.${field.name} of type ${fieldType ? fieldType.toString() : '*unknown*'} not supported. Consider specifying column type with: 353 | """ 354 | @db.type: "text" 355 | """ 356 | as the field comment.`) 357 | return null 358 | } 359 | 360 | // Index 361 | for (const indexTypeDef of INDEX_TYPES) { 362 | const annotation = annotations[indexTypeDef.annotation] 363 | if (this.currentTable && (annotation || 364 | (indexTypeDef.default && isScalarType(fieldType) && 365 | indexTypeDef.default(field.name, fieldType.name) && annotation !== false)) 366 | ) { 367 | let indexName: string | null = null 368 | let indexType: string | null = null 369 | if (typeof annotation === 'string') { 370 | indexName = annotation 371 | } else if (indexTypeDef.hasType && typeof annotation === 'object') { 372 | indexName = annotation.name 373 | indexType = annotation.type 374 | } 375 | // @ts-ignore 376 | const list: any[] = this.currentTable[indexTypeDef.list] 377 | let index = indexName ? list.find((i) => i.name === indexName) : null 378 | if (!index) { 379 | index = indexTypeDef.hasType ? { 380 | name: indexName, 381 | type: indexType, 382 | columns: [], 383 | } : { 384 | name: indexName, 385 | columns: [], 386 | } 387 | if (indexTypeDef.max && list.length === indexTypeDef.max) { 388 | list.splice(0, 1) 389 | } 390 | list.push(index) 391 | } 392 | index.columns.push(columnName) 393 | if (!index.name) { 394 | index.name = indexTypeDef.defaultName(this.currentTable.name, columnName).substr(0, 63) 395 | } 396 | } 397 | } 398 | 399 | return { 400 | name: columnName, 401 | comment: escapeComment(stripAnnotations(field.description || null)), 402 | annotations, 403 | type, 404 | args: args || [], 405 | nullable: !notNull, 406 | foreign, 407 | defaultValue: annotations.default != null ? annotations.default : null, 408 | } 409 | } 410 | 411 | /** 412 | * Put the correct values for `foreign.tableName` and `foreign.columnName` in the columns. 413 | */ 414 | private fillForeignKeys () { 415 | for (const table of this.database.tables) { 416 | for (const column of table.columns) { 417 | if (column.foreign) { 418 | const foreignTable = this.database.tableMap.get(column.foreign.type || '') 419 | if (!foreignTable) { 420 | console.warn(`Foreign key ${table.name}.${column.name}: Table not found for type ${column.foreign.type}.`) 421 | continue 422 | } 423 | const foreignColumn = foreignTable.columnMap.get(column.foreign.field || '') 424 | if (!foreignColumn) { 425 | // tslint:disable-next-line max-line-length 426 | console.warn(`Foreign key ${table.name}.${column.name}: Column not found for field ${column.foreign.field} in table ${foreignTable.name}.`) 427 | continue 428 | } 429 | column.foreign.tableName = foreignTable.name 430 | column.foreign.columnName = foreignColumn.name 431 | } 432 | } 433 | } 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /src/abstract/getColumnTypeFromScalar.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLField, GraphQLScalarType } from 'graphql' 2 | import { TableColumnType } from './TableColumn' 3 | import { parseAnnotations } from 'graphql-annotations' 4 | 5 | export interface TableColumnTypeDescriptor { 6 | /** 7 | * Knex column builder function name. 8 | */ 9 | type: TableColumnType | string 10 | /** 11 | * Builder function arguments. 12 | */ 13 | args: any[] 14 | } 15 | 16 | export default function ( 17 | field: GraphQLField, 18 | scalarType: GraphQLScalarType | null = null, 19 | annotations: any = null, 20 | ): TableColumnTypeDescriptor | null { 21 | if (!annotations) { 22 | annotations = parseAnnotations('db', field.description || null) 23 | } 24 | 25 | // text 26 | if (annotations.type === 'text') { 27 | return { 28 | type: 'text', 29 | args: [], 30 | } 31 | } 32 | 33 | // string 34 | if ((scalarType && scalarType.name === 'String') || annotations.type === 'string') { 35 | return { 36 | type: 'string', 37 | args: [annotations.length || 255], 38 | } 39 | } 40 | 41 | // integer 42 | if ((scalarType && scalarType.name === 'Int') || annotations.type === 'integer') { 43 | return { 44 | type: 'integer', 45 | args: [], 46 | } 47 | } 48 | 49 | // float 50 | if ((scalarType && scalarType.name === 'Float') || annotations.type === 'float') { 51 | return { 52 | type: 'float', 53 | args: [annotations.precision, annotations.scale], 54 | } 55 | } 56 | 57 | // boolean 58 | if ((scalarType && scalarType.name === 'Boolean') || annotations.type === 'boolean') { 59 | return { 60 | type: 'boolean', 61 | args: [], 62 | } 63 | } 64 | 65 | // date 66 | if (annotations.type === 'date') { 67 | return { 68 | type: 'date', 69 | args: [], 70 | } 71 | } 72 | 73 | // datetime & time 74 | if (['datetime', 'time'].includes(annotations.type)) { 75 | return { 76 | type: annotations.type, 77 | args: [annotations.precision], 78 | } 79 | } 80 | 81 | // timestamp 82 | if (annotations.type === 'timestamp') { 83 | return { 84 | type: 'timestamp', 85 | args: [annotations.useTz, annotations.precision], 86 | } 87 | } 88 | 89 | // binary 90 | if (annotations.type === 'binary') { 91 | return { 92 | type: 'binary', 93 | args: [annotations.length], 94 | } 95 | } 96 | 97 | // json & jsonb 98 | if (['json', 'jsonb'].includes(annotations.type)) { 99 | return { 100 | type: annotations.type, 101 | args: [], 102 | } 103 | } 104 | 105 | // uuid 106 | if ((scalarType && scalarType.name === 'ID') || annotations.type === 'uuid') { 107 | return { 108 | type: 'uuid', 109 | args: [], 110 | } 111 | } 112 | 113 | // unknown type 114 | if (annotations.type) { 115 | return { 116 | type: annotations.type, 117 | args: annotations.args || [], 118 | } 119 | } 120 | 121 | return null 122 | } 123 | -------------------------------------------------------------------------------- /src/connector/read.ts: -------------------------------------------------------------------------------- 1 | import Knex, { Config, ColumnInfo } from 'knex' 2 | import { AbstractDatabase } from '../abstract/AbstractDatabase' 3 | import { Table } from '../abstract/Table' 4 | import { TableColumn } from '../abstract/TableColumn' 5 | import listTables from '../util/listTables' 6 | import getTypeAlias from '../util/getTypeAlias' 7 | import getColumnComments from '../util/getColumnComments' 8 | import transformDefaultValue from '../util/transformDefaultValue' 9 | import getPrimaryKey from '../util/getPrimaryKey' 10 | import getForeignKeys from '../util/getForeignKeys' 11 | import getIndexes from '../util/getIndexes' 12 | import getUniques from '../util/getUniques' 13 | import getCheckConstraints from '../util/getCheckConstraints' 14 | 15 | /** 16 | * @param {Config} config Knex configuration 17 | * @param {string} schemaName Table and column prefix: `.` 18 | * @param {string} tablePrefix Table name prefix: `` 19 | * @param {string} columnPrefix Column name prefix: `` 20 | */ 21 | export function read ( 22 | config: Config, 23 | schemaName = 'public', 24 | tablePrefix = '', 25 | columnPrefix = '', 26 | ): Promise { 27 | const reader = new Reader(config, schemaName, tablePrefix, columnPrefix) 28 | return reader.read() 29 | } 30 | 31 | class Reader { 32 | public config: Config 33 | public schemaName: string 34 | public tablePrefix: string 35 | public columnPrefix: string 36 | public knex: Knex 37 | public database: AbstractDatabase 38 | 39 | constructor ( 40 | config: Config, 41 | schemaName: string, 42 | tablePrefix: string, 43 | columnPrefix: string, 44 | ) { 45 | this.config = config 46 | this.schemaName = schemaName 47 | this.tablePrefix = tablePrefix 48 | this.columnPrefix = columnPrefix 49 | this.knex = Knex(config) 50 | this.database = { 51 | tables: [], 52 | tableMap: new Map(), 53 | } 54 | } 55 | 56 | public async read () { 57 | const tables: Array<{ name: string, comment: string }> = await listTables(this.knex, this.schemaName) 58 | for (const { name: tableName, comment } of tables) { 59 | const name = this.getTableName(tableName) 60 | if (!name) { continue } 61 | const table: Table = { 62 | name, 63 | comment, 64 | annotations: {}, 65 | columns: [], 66 | columnMap: new Map(), 67 | primaries: [], 68 | indexes: [], 69 | uniques: [], 70 | } 71 | this.database.tables.push(table) 72 | this.database.tableMap.set(name, table) 73 | 74 | // Foreign keys 75 | const foreignKeys = await getForeignKeys(this.knex, tableName, this.schemaName) 76 | 77 | const checkContraints = await getCheckConstraints(this.knex, tableName, this.schemaName) 78 | 79 | // Columns 80 | const columnComments = await getColumnComments(this.knex, tableName, this.schemaName) 81 | const columnInfo: { [key: string]: ColumnInfo } = await this.knex(tableName) 82 | .withSchema(this.schemaName) 83 | .columnInfo() as any 84 | for (const key in columnInfo) { 85 | if (columnInfo[key]) { 86 | const columnName = this.getColumnName(key) 87 | if (!columnName) { continue } 88 | const info = columnInfo[key] 89 | const foreign = foreignKeys.find((k) => k.column === key) 90 | const column: TableColumn = { 91 | name: columnName, 92 | comment: this.getComment(columnComments, key), 93 | annotations: {}, 94 | ...getTypeAlias(info.type, info.maxLength), 95 | nullable: info.nullable, 96 | defaultValue: transformDefaultValue(info.defaultValue), 97 | foreign: foreign ? { 98 | type: null, 99 | field: null, 100 | tableName: this.getTableName(foreign.foreignTable), 101 | columnName: this.getColumnName(foreign.foreignColumn), 102 | } : null, 103 | } 104 | 105 | const checkContraint = checkContraints.find((c: any) => c.columnNames.includes(columnName)) 106 | if (checkContraint && checkContraint.values) { 107 | column.type = 'enum' 108 | column.args = [checkContraint.values] 109 | } 110 | 111 | table.columns.push(column) 112 | table.columnMap.set(key, column) 113 | } 114 | } 115 | 116 | // Primary key 117 | const primaries = await getPrimaryKey(this.knex, tableName, this.schemaName) 118 | table.primaries = primaries.map((p) => ({ 119 | columns: this.getColumnNames([p.column]), 120 | name: p.indexName, 121 | })) 122 | 123 | // Index 124 | const indexes = await getIndexes(this.knex, tableName, this.schemaName) 125 | table.indexes = indexes.filter( 126 | (i) => i.columnNames.length > 1 || 127 | // Not already the primary key 128 | !primaries.find((p) => p.column === i.columnNames[0]), 129 | ).map((i) => ({ 130 | name: i.indexName, 131 | columns: this.getColumnNames(i.columnNames), 132 | type: i.type, 133 | })) 134 | 135 | // Unique constraints 136 | const uniques = await getUniques(this.knex, tableName, this.schemaName) 137 | table.uniques = uniques.map((u) => ({ 138 | columns: this.getColumnNames(u.columnNames), 139 | name: u.indexName, 140 | })) 141 | 142 | // Deduplicate unique index 143 | for (const unique of table.uniques) { 144 | for (let i = 0; i < table.indexes.length; i++) { 145 | if (unique.name === table.indexes[i].name) { 146 | table.indexes.splice(i, 1) 147 | break 148 | } 149 | } 150 | } 151 | } 152 | 153 | await this.knex.destroy() 154 | 155 | return this.database 156 | } 157 | 158 | private getTableName (name: string) { 159 | if (name.startsWith(this.tablePrefix)) { 160 | return name.substr(this.tablePrefix.length) 161 | } 162 | return null 163 | } 164 | 165 | private getColumnName (name: string) { 166 | if (name.startsWith(this.columnPrefix)) { 167 | return name.substr(this.columnPrefix.length) 168 | } 169 | return null 170 | } 171 | 172 | private getColumnNames (names: string[]): string [] { 173 | // @ts-ignore 174 | return names.map((name) => this.getColumnName(name)).filter((n) => !!n) 175 | } 176 | 177 | private getComment (comments: Array<{ column: string, comment: string }>, column: string) { 178 | const row = comments.find((c) => c.column === column) 179 | if (row && row.comment != null) { 180 | return row.comment.replace(/'/g, `''`) 181 | } 182 | return null 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/connector/write.ts: -------------------------------------------------------------------------------- 1 | import Knex, { Config, CreateTableBuilder, TableBuilder } from 'knex' 2 | // eslint-disable-next-line import/no-duplicates 3 | import * as Operations from '../diff/Operation' 4 | // eslint-disable-next-line import/no-duplicates 5 | import { Operation, OperationType } from '../diff/Operation' 6 | import { MigratePlugin, WriteCallback } from '../plugin/MigratePlugin' 7 | import { sortOps } from '../util/sortOps' 8 | 9 | const CREATE_TABLE_CHILD_OPS: OperationType[] = [ 10 | 'table.comment.set', 11 | 'table.index.create', 12 | 'table.unique.create', 13 | 'table.primary.set', 14 | 'column.create', 15 | ] 16 | 17 | const ALTER_TABLE_CHILD_OPS: OperationType[] = [ 18 | ...CREATE_TABLE_CHILD_OPS, 19 | 'column.rename', 20 | 'column.alter', 21 | 'column.drop', 22 | 'table.foreign.drop', 23 | 'table.index.drop', 24 | 'table.unique.drop', 25 | ] 26 | 27 | /** 28 | * @param {Operation[]} operations 29 | * @param {Config} config Knex configuration 30 | * @param {string} schemaName Table schema prefix: `.` 31 | * @param {string} tablePrefix Table name prefix: `` 32 | * @param {string} columnPrefix Column name prefix: `` 33 | */ 34 | export async function write ( 35 | operations: Operation[], 36 | config: Config, 37 | schemaName = 'public', 38 | tablePrefix = '', 39 | columnPrefix = '', 40 | plugins: MigratePlugin[] = [], 41 | ) { 42 | const writer = new Writer( 43 | operations, 44 | config, 45 | schemaName, 46 | tablePrefix, 47 | columnPrefix, 48 | plugins, 49 | ) 50 | return writer.write() 51 | } 52 | 53 | class Writer { 54 | private operations: Operation[] 55 | private schemaName: string 56 | private tablePrefix: string 57 | private columnPrefix: string 58 | private plugins: MigratePlugin[] 59 | private knex: Knex 60 | private hooks: { [key: string]: WriteCallback[] } = {} 61 | // @ts-ignore 62 | private trx: Knex.Transaction 63 | 64 | constructor ( 65 | operations: Operation[], 66 | config: Config, 67 | schemaName = 'public', 68 | tablePrefix = '', 69 | columnPrefix = '', 70 | plugins: MigratePlugin[], 71 | ) { 72 | this.operations = operations.slice().sort(sortOps) 73 | this.schemaName = schemaName 74 | this.tablePrefix = tablePrefix 75 | this.columnPrefix = columnPrefix 76 | this.plugins = plugins 77 | this.knex = Knex(config) 78 | } 79 | 80 | public async write () { 81 | await this.applyPlugins() 82 | 83 | await this.knex.transaction(async (trx) => { 84 | this.trx = trx 85 | let op: Operation | undefined 86 | while ((op = this.operations.shift())) { 87 | switch (op.type) { 88 | case 'table.create': 89 | await this.createTable(op as Operations.TableCreateOperation) 90 | break 91 | case 'table.rename': 92 | await this.callHook(op, 'before') 93 | const trop = (op as Operations.TableRenameOperation) 94 | await this.trx.schema.withSchema(this.schemaName) 95 | .renameTable(this.getTableName(trop.fromName), this.getTableName(trop.toName)) 96 | await this.callHook(op, 'after') 97 | break 98 | case 'table.drop': 99 | await this.callHook(op, 'before') 100 | await this.dropTable(op as Operations.TableDropOperation) 101 | await this.callHook(op, 'after') 102 | break 103 | case 'table.foreign.create': 104 | const tfop = (op as Operations.TableForeignCreateOperation) 105 | await this.trx.schema.withSchema(this.schemaName).alterTable(tfop.table, (table) => { 106 | table.foreign(this.getColumnName(tfop.column)) 107 | .references(this.getColumnName(tfop.referenceColumn)) 108 | .inTable(this.getTableName(tfop.referenceTable)) 109 | }) 110 | break 111 | default: 112 | this.operations.splice(0, 0, op) 113 | await this.alterTable((op as any).table) 114 | } 115 | } 116 | }) 117 | 118 | await this.knex.destroy() 119 | } 120 | 121 | private getTableName (name: string) { 122 | return `${this.tablePrefix}${name}` 123 | } 124 | 125 | private getColumnName (name: string) { 126 | return `${this.columnPrefix}${name}` 127 | } 128 | 129 | private getColumnNames (names: string[]) { 130 | return names.map((name) => this.getColumnName(name)) 131 | } 132 | 133 | private removeOperation (op: Operation) { 134 | const index = this.operations.indexOf(op) 135 | if (index !== -1) { this.operations.splice(index, 1) } 136 | } 137 | 138 | private async applyPlugins () { 139 | this.hooks = {} 140 | for (const plugin of this.plugins) { 141 | plugin.write({ 142 | tap: (type, event, callback) => { 143 | const key = `${type}.${event}` 144 | const list = this.hooks[key] = this.hooks[key] || [] 145 | list.push(callback) 146 | }, 147 | }) 148 | } 149 | } 150 | 151 | private async callHook (op: Operation, event: 'before' | 'after') { 152 | const list = this.hooks[`${op.type}.${event}`] 153 | if (list) { 154 | for (const callback of list) { 155 | await callback(op, this.trx) 156 | } 157 | } 158 | } 159 | 160 | private async createTable (op: Operations.TableCreateOperation) { 161 | await this.callHook(op, 'before') 162 | const childOps: Operation[] = this.operations.filter( 163 | (child) => CREATE_TABLE_CHILD_OPS.includes(child.type) && 164 | (child as any).table === op.table, 165 | ) 166 | for (const childOp of childOps) { 167 | await this.callHook(childOp, 'before') 168 | } 169 | await this.trx.schema.withSchema(this.schemaName).createTable(this.getTableName(op.table), async (table) => { 170 | for (const childOp of childOps) { 171 | switch (childOp.type) { 172 | case 'column.create': 173 | this.createColumn(childOp as Operations.ColumnCreateOperation, table) 174 | break 175 | case 'table.comment.set': 176 | table.comment((childOp as Operations.TableCommentSetOperation).comment || '') 177 | break 178 | case 'table.index.create': 179 | const tiop = (childOp as Operations.TableIndexCreateOperation) 180 | table.index(this.getColumnNames(tiop.columns), tiop.indexName || undefined, tiop.indexType || undefined) 181 | break 182 | case 'table.unique.create': 183 | const tuop = (childOp as Operations.TableUniqueCreateOperation) 184 | table.unique(this.getColumnNames(tuop.columns), tuop.indexName || undefined) 185 | break 186 | case 'table.primary.set': 187 | const tpop = (childOp as Operations.TablePrimarySetOperation) 188 | if (tpop.columns) { 189 | // @ts-ignore 190 | table.primary(this.getColumnNames(tpop.columns), tpop.indexName) 191 | } 192 | break 193 | } 194 | this.removeOperation(childOp) 195 | } 196 | }) 197 | for (const childOp of childOps) { 198 | await this.callHook(childOp, 'after') 199 | } 200 | await this.callHook(op, 'after') 201 | } 202 | 203 | private createColumn (op: Operations.ColumnCreateOperation, table: CreateTableBuilder) { 204 | if (op.columnType in table) { 205 | // @ts-ignore 206 | let col: Knex.ColumnBuilder = table[op.columnType]( 207 | this.getColumnName(op.column), 208 | ...this.getColumnTypeArgs(op), 209 | ) 210 | if (op.comment) { 211 | col = col.comment(op.comment) 212 | } 213 | if (op.nullable) { 214 | col = col.nullable() 215 | } else { 216 | col = col.notNullable() 217 | } 218 | if (typeof op.defaultValue !== 'undefined') { 219 | col = col.defaultTo(op.defaultValue) 220 | } 221 | return col 222 | } else { 223 | throw new Error(`Table ${op.table} column ${op.column}: Unsupported column type ${op.columnType}`) 224 | } 225 | } 226 | 227 | private async alterTable (tableName: string) { 228 | const allChildOps = this.operations.filter( 229 | (child) => ALTER_TABLE_CHILD_OPS.includes(child.type) && 230 | (child as any).table === tableName, 231 | ) 232 | const childOps: Operations.Operation[] = [] 233 | for (const childOp of allChildOps) { 234 | await this.callHook(childOp, 'before') 235 | 236 | let add = true 237 | 238 | if (childOp.type === 'column.alter') { 239 | const aop = childOp as Operations.ColumnAlterOperation 240 | if (aop.columnType === 'enum' && (aop.args.length < 2 || !aop.args[1].useNative)) { 241 | // Prepared statement no supported here 242 | const constraintName = this.knex.raw(`${tableName}_${aop.column}_check`) 243 | await this.trx.raw(`ALTER TABLE "?"."?" DROP CONSTRAINT "?", ADD CONSTRAINT "?" CHECK (? IN (?)) `, [ 244 | this.knex.raw(this.schemaName), 245 | this.knex.raw(tableName), 246 | constraintName, 247 | constraintName, 248 | this.knex.raw(aop.column), 249 | this.knex.raw(aop.args[0].map((s: string) => `'${s.replace(`'`, `\\'`)}'`).join(', ')), 250 | ]) 251 | this.removeOperation(childOp) 252 | add = false 253 | } 254 | } 255 | 256 | if (add) { 257 | childOps.push(childOp) 258 | } 259 | } 260 | await this.trx.schema.withSchema(this.schemaName).alterTable(this.getTableName(tableName), async (table) => { 261 | for (const childOp of childOps) { 262 | switch (childOp.type) { 263 | case 'table.comment.set': 264 | table.comment((childOp as Operations.TableCommentSetOperation).comment || '') 265 | break 266 | case 'table.foreign.drop': 267 | const tfdop = (childOp as Operations.TableForeignDropOperation) 268 | table.dropForeign([this.getColumnName(tfdop.column)]) 269 | break 270 | case 'table.index.create': 271 | const tiop = (childOp as Operations.TableIndexCreateOperation) 272 | table.index(this.getColumnNames(tiop.columns), tiop.indexName || undefined, tiop.indexType || undefined) 273 | break 274 | case 'table.index.drop': 275 | const tidop = (childOp as Operations.TableIndexDropOperation) 276 | table.dropIndex(this.getColumnNames(tidop.columns), tidop.indexName || undefined) 277 | break 278 | case 'table.unique.create': 279 | const tuop = (childOp as Operations.TableUniqueCreateOperation) 280 | table.unique(this.getColumnNames(tuop.columns), tuop.indexName || undefined) 281 | break 282 | case 'table.unique.drop': 283 | const tudop = (childOp as Operations.TableUniqueDropOperation) 284 | table.dropUnique(this.getColumnNames(tudop.columns), tudop.indexName || undefined) 285 | break 286 | case 'table.primary.set': 287 | const tpop = (childOp as Operations.TablePrimarySetOperation) 288 | if (tpop.columns) { 289 | // @ts-ignore 290 | table.primary(this.getColumnNames(tpop.columns), tpop.indexName) 291 | } else { 292 | // @ts-ignore 293 | table.dropPrimary(tpop.indexName) 294 | } 295 | break 296 | case 'column.create': 297 | this.createColumn(childOp as Operations.ColumnCreateOperation, table) 298 | break 299 | case 'column.rename': 300 | const crop = (childOp as Operations.ColumnRenameOperation) 301 | table.renameColumn(this.getColumnName(crop.fromName), this.getColumnName(crop.toName)) 302 | break 303 | case 'column.alter': 304 | this.alterColumn(table, (childOp as Operations.ColumnAlterOperation)) 305 | break 306 | case 'column.drop': 307 | table.dropColumn(this.getColumnName((childOp as Operations.ColumnDropOperation).column)) 308 | break 309 | } 310 | this.removeOperation(childOp) 311 | } 312 | }) 313 | for (const childOp of childOps) { 314 | await this.callHook(childOp, 'after') 315 | } 316 | } 317 | 318 | private alterColumn (table: TableBuilder, op: Operations.ColumnAlterOperation) { 319 | // @ts-ignore 320 | let col: Knex.ColumnBuilder = table[op.columnType]( 321 | op.column, 322 | ...this.getColumnTypeArgs(op), 323 | ) 324 | if (op.comment) { 325 | col = col.comment(op.comment) 326 | } 327 | if (op.nullable) { 328 | col = col.nullable() 329 | } else { 330 | col = col.notNullable() 331 | } 332 | if (typeof op.defaultValue !== 'undefined') { 333 | col = col.defaultTo(op.defaultValue) 334 | } 335 | col = col.alter() 336 | return col 337 | } 338 | 339 | private async dropTable (op: Operations.TableDropOperation) { 340 | if (['pg', 'mysql', 'mysql2'].includes(this.knex.client.config.client)) { 341 | await this.trx.raw(`DROP TABLE ?.? CASCADE`, [this.trx.raw(this.schemaName), this.trx.raw(op.table)]) 342 | } else { 343 | await this.trx.schema.withSchema(this.schemaName).dropTable(this.getTableName(op.table)) 344 | } 345 | } 346 | 347 | private getColumnTypeArgs (op: Operations.ColumnCreateOperation | Operations.ColumnAlterOperation) { 348 | let args: any[] = op.args 349 | const dbType: string = this.knex.client.config.client 350 | if (op.columnType === 'timestamp' && args.length) { 351 | if (dbType === 'pg') { 352 | args = [!args[0]] 353 | } else if (dbType === 'mssql') { 354 | args = [{ useTz: args[0] }] 355 | } else { 356 | args = [] 357 | } 358 | } 359 | return args 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /src/diff/Operation.ts: -------------------------------------------------------------------------------- 1 | export type OperationType = 2 | 'table.create' | 3 | 'table.rename' | 4 | 'table.comment.set' | 5 | 'table.drop' | 6 | 'table.index.create' | 7 | 'table.index.drop' | 8 | 'table.primary.set' | 9 | 'table.unique.create' | 10 | 'table.unique.drop' | 11 | 'table.foreign.create' | 12 | 'table.foreign.drop' | 13 | 'column.create' | 14 | 'column.rename' | 15 | 'column.alter' | 16 | 'column.drop' 17 | 18 | export interface Operation { 19 | type: OperationType 20 | priority: number 21 | } 22 | 23 | export interface TableCreateOperation extends Operation { 24 | type: 'table.create' 25 | table: string 26 | } 27 | 28 | export interface TableRenameOperation extends Operation { 29 | type: 'table.rename' 30 | fromName: string 31 | toName: string 32 | } 33 | 34 | export interface TableCommentSetOperation extends Operation { 35 | type: 'table.comment.set' 36 | table: string 37 | comment: string | null 38 | } 39 | 40 | export interface TableDropOperation extends Operation { 41 | type: 'table.drop' 42 | table: string 43 | } 44 | 45 | export interface TableIndexCreateOperation extends Operation { 46 | type: 'table.index.create' 47 | table: string 48 | columns: string[] 49 | indexName: string | null 50 | indexType: string | null 51 | } 52 | 53 | export interface TableIndexDropOperation extends Operation { 54 | type: 'table.index.drop' 55 | table: string 56 | columns: string[] 57 | indexName: string | null 58 | } 59 | 60 | export interface TablePrimarySetOperation extends Operation { 61 | type: 'table.primary.set' 62 | table: string 63 | columns: string[] | null 64 | indexName: string | null 65 | } 66 | 67 | export interface TableUniqueCreateOperation extends Operation { 68 | type: 'table.unique.create' 69 | table: string 70 | columns: string[] 71 | indexName: string | null 72 | } 73 | 74 | export interface TableUniqueDropOperation extends Operation { 75 | type: 'table.unique.drop' 76 | table: string 77 | columns: string[] 78 | indexName: string | null 79 | } 80 | 81 | export interface TableForeignCreateOperation extends Operation { 82 | type: 'table.foreign.create' 83 | table: string 84 | column: string 85 | referenceTable: string 86 | referenceColumn: string 87 | } 88 | 89 | export interface TableForeignDropOperation extends Operation { 90 | type: 'table.foreign.drop' 91 | table: string 92 | column: string 93 | } 94 | 95 | export interface ColumnCreateOperation extends Operation { 96 | type: 'column.create' 97 | table: string 98 | column: string 99 | columnType: string 100 | args: any[] 101 | comment: string | null 102 | nullable: boolean 103 | defaultValue: any 104 | } 105 | 106 | export interface ColumnAlterOperation extends Operation { 107 | type: 'column.alter' 108 | table: string 109 | column: string 110 | columnType: string 111 | args: any[] 112 | comment: string | null 113 | nullable: boolean 114 | defaultValue: any 115 | } 116 | 117 | export interface ColumnRenameOperation extends Operation { 118 | type: 'column.rename' 119 | table: string 120 | fromName: string 121 | toName: string 122 | } 123 | 124 | export interface ColumnDropOperation extends Operation { 125 | type: 'column.drop' 126 | table: string 127 | column: string 128 | } 129 | -------------------------------------------------------------------------------- /src/diff/computeDiff.ts: -------------------------------------------------------------------------------- 1 | import { AbstractDatabase } from '../abstract/AbstractDatabase' 2 | import { Table, TablePrimary, TableIndex, TableUnique } from '../abstract/Table' 3 | import { TableColumn } from '../abstract/TableColumn' 4 | import * as Operations from './Operation' 5 | // @ts-ignore 6 | import isEqual from 'lodash.isequal' 7 | 8 | export async function computeDiff (from: AbstractDatabase, to: AbstractDatabase, { 9 | updateComments = false, 10 | } = {}): Promise { 11 | const differ = new Differ(from, to, { 12 | updateComments, 13 | }) 14 | return differ.diff() 15 | } 16 | 17 | class Differ { 18 | private from: AbstractDatabase 19 | private to: AbstractDatabase 20 | private updateComments: boolean 21 | private operations: Operations.Operation[] = [] 22 | private tableCount = 0 23 | 24 | constructor (from: AbstractDatabase, to: AbstractDatabase, options: any) { 25 | this.from = from 26 | this.to = to 27 | this.updateComments = options.updateComments 28 | } 29 | 30 | public diff (): Operations.Operation[] { 31 | this.operations.length = 0 32 | 33 | const sameTableQueue: Array<{ fromTable: Table, toTable: Table }> = [] 34 | const addTableQueue = this.to.tables.slice() 35 | for (const fromTable of this.from.tables) { 36 | let removed = true 37 | for (let i = 0, l = addTableQueue.length; i < l; i++) { 38 | const toTable = addTableQueue[i] 39 | // Same table 40 | if (toTable.name === fromTable.name) { 41 | removed = false 42 | } 43 | 44 | // Rename table 45 | const { annotations } = toTable 46 | if (annotations && annotations.oldNames && annotations.oldNames.includes(fromTable.name)) { 47 | removed = false 48 | this.renameTable(fromTable, toTable) 49 | } 50 | 51 | // Same or Rename 52 | if (!removed) { 53 | sameTableQueue.push({ fromTable, toTable }) 54 | // A new table shouldn't be added 55 | addTableQueue.splice(i, 1) 56 | break 57 | } 58 | } 59 | 60 | // Drop table 61 | if (removed) { 62 | this.dropTable(fromTable) 63 | } 64 | } 65 | 66 | // Create table 67 | for (const toTable of addTableQueue) { 68 | this.createTable(toTable) 69 | } 70 | 71 | // Compare tables 72 | for (const { fromTable, toTable } of sameTableQueue) { 73 | // Comment 74 | if (this.updateComments && fromTable.comment !== toTable.comment) { 75 | this.setTableComment(toTable) 76 | } 77 | 78 | const sameColumnQueue: Array<{ fromCol: TableColumn, toCol: TableColumn }> = [] 79 | const addColumnQueue = toTable.columns.slice() 80 | for (const fromCol of fromTable.columns) { 81 | let removed = true 82 | for (let i = 0, l = addColumnQueue.length; i < l; i++) { 83 | const toCol = addColumnQueue[i] 84 | // Same column 85 | if (toCol.name === fromCol.name) { 86 | removed = false 87 | } 88 | 89 | // Rename column 90 | const { annotations } = toCol 91 | if (annotations && annotations.oldNames && annotations.oldNames.includes(fromCol.name)) { 92 | removed = false 93 | this.renameColumn(toTable, fromCol, toCol) 94 | } 95 | 96 | // Same or Rename 97 | if (!removed) { 98 | sameColumnQueue.push({ fromCol, toCol }) 99 | // A new table shouldn't be added 100 | addColumnQueue.splice(i, 1) 101 | break 102 | } 103 | } 104 | 105 | // Drop column 106 | if (removed) { 107 | this.dropColumn(fromTable, fromCol) 108 | } 109 | } 110 | 111 | // Add columns 112 | for (const column of addColumnQueue) { 113 | this.createColumn(toTable, column) 114 | } 115 | 116 | // Compare columns 117 | for (const { fromCol, toCol } of sameColumnQueue) { 118 | // Comment 119 | if ((this.updateComments && fromCol.comment !== toCol.comment) || 120 | fromCol.type !== toCol.type || !isEqual(fromCol.args, toCol.args) || 121 | fromCol.nullable !== toCol.nullable || 122 | fromCol.defaultValue !== toCol.defaultValue || 123 | (Array.isArray(fromCol.defaultValue) && !isEqual(fromCol.defaultValue, toCol.defaultValue)) || 124 | (typeof fromCol.defaultValue === 'object' && !isEqual(fromCol.defaultValue, toCol.defaultValue)) 125 | ) { 126 | this.alterColumn(toTable, toCol) 127 | } 128 | 129 | // Foreign key 130 | if ((fromCol.foreign && !toCol.foreign) || 131 | (!fromCol.foreign && toCol.foreign) || 132 | (fromCol.foreign && toCol.foreign && 133 | (fromCol.foreign.tableName !== toCol.foreign.tableName || 134 | fromCol.foreign.columnName !== toCol.foreign.columnName) 135 | )) { 136 | if (fromCol.foreign) { this.dropForeignKey(toTable, fromCol) } 137 | if (toCol.foreign) { this.createForeignKey(toTable, toCol) } 138 | } 139 | } 140 | 141 | // Primary index 142 | if (!isEqual(fromTable.primaries, toTable.primaries)) { 143 | const [index] = toTable.primaries 144 | this.setPrimary(toTable, index) 145 | } 146 | 147 | // Index 148 | this.compareIndex( 149 | fromTable.indexes, 150 | toTable.indexes, 151 | // @ts-ignore 152 | (index: TableIndex) => this.createIndex(toTable, index), 153 | (index: TableIndex) => this.dropIndex(fromTable, index), 154 | ) 155 | 156 | // Unique contraint 157 | this.compareIndex( 158 | fromTable.uniques, 159 | toTable.uniques, 160 | (index: TableUnique) => this.createUnique(toTable, index), 161 | (index: TableUnique) => this.dropUnique(fromTable, index), 162 | ) 163 | } 164 | 165 | return this.operations 166 | } 167 | 168 | private createTable (table: Table) { 169 | const op: Operations.TableCreateOperation = { 170 | type: 'table.create', 171 | table: table.name, 172 | priority: this.tableCount++, 173 | } 174 | this.operations.push(op) 175 | 176 | // Comment 177 | if (table.comment) { 178 | this.setTableComment(table) 179 | } 180 | 181 | // Columns 182 | for (const column of table.columns) { 183 | this.createColumn(table, column) 184 | } 185 | 186 | // Primary index 187 | if (table.primaries.length) { 188 | const [index] = table.primaries 189 | this.setPrimary(table, index) 190 | } 191 | 192 | // Index 193 | for (const index of table.indexes) { 194 | this.createIndex(table, index) 195 | } 196 | 197 | // Unique contraint 198 | for (const index of table.uniques) { 199 | this.createUnique(table, index) 200 | } 201 | } 202 | 203 | private renameTable (fromTable: Table, toTable: Table) { 204 | const op: Operations.TableRenameOperation = { 205 | type: 'table.rename', 206 | fromName: fromTable.name, 207 | toName: toTable.name, 208 | priority: 0, 209 | } 210 | this.operations.push(op) 211 | } 212 | 213 | private dropTable (table: Table) { 214 | const op: Operations.TableDropOperation = { 215 | type: 'table.drop', 216 | table: table.name, 217 | priority: 0, 218 | } 219 | this.operations.push(op) 220 | } 221 | 222 | private setTableComment (table: Table) { 223 | const op: Operations.TableCommentSetOperation = { 224 | type: 'table.comment.set', 225 | table: table.name, 226 | comment: table.comment, 227 | priority: 0, 228 | } 229 | this.operations.push(op) 230 | } 231 | 232 | private setPrimary (table: Table, index: TablePrimary | null) { 233 | const op: Operations.TablePrimarySetOperation = { 234 | type: 'table.primary.set', 235 | table: table.name, 236 | columns: index ? index.columns : null, 237 | indexName: index ? index.name : null, 238 | priority: 0, 239 | } 240 | this.operations.push(op) 241 | } 242 | 243 | private createIndex (table: Table, index: TableIndex) { 244 | const op: Operations.TableIndexCreateOperation = { 245 | type: 'table.index.create', 246 | table: table.name, 247 | columns: index.columns, 248 | indexName: index.name, 249 | indexType: index.type, 250 | priority: 0, 251 | } 252 | this.operations.push(op) 253 | } 254 | 255 | private dropIndex (table: Table, index: TableIndex) { 256 | const op: Operations.TableIndexDropOperation = { 257 | type: 'table.index.drop', 258 | table: table.name, 259 | columns: index.columns, 260 | indexName: index.name, 261 | priority: 0, 262 | } 263 | this.operations.push(op) 264 | } 265 | 266 | private createUnique (table: Table, index: TableUnique) { 267 | const op: Operations.TableUniqueCreateOperation = { 268 | type: 'table.unique.create', 269 | table: table.name, 270 | columns: index.columns, 271 | indexName: index.name, 272 | priority: 0, 273 | } 274 | this.operations.push(op) 275 | } 276 | 277 | /** 278 | * @param {Table} table 279 | * @param {TableUnique} index 280 | */ 281 | private dropUnique (table: Table, index: TableUnique) { 282 | const op: Operations.TableUniqueDropOperation = { 283 | type: 'table.unique.drop', 284 | table: table.name, 285 | columns: index.columns, 286 | indexName: index.name, 287 | priority: 0, 288 | } 289 | this.operations.push(op) 290 | } 291 | 292 | private createForeignKey (table: Table, column: TableColumn) { 293 | if (column.foreign && column.foreign.tableName && column.foreign.columnName) { 294 | const op: Operations.TableForeignCreateOperation = { 295 | type: 'table.foreign.create', 296 | table: table.name, 297 | column: column.name, 298 | referenceTable: column.foreign.tableName, 299 | referenceColumn: column.foreign.columnName, 300 | priority: 0, 301 | } 302 | this.operations.push(op) 303 | } 304 | } 305 | 306 | private dropForeignKey (table: Table, column: TableColumn) { 307 | if (column.foreign) { 308 | const op: Operations.TableForeignDropOperation = { 309 | type: 'table.foreign.drop', 310 | table: table.name, 311 | column: column.name, 312 | priority: 0, 313 | } 314 | this.operations.push(op) 315 | } 316 | } 317 | 318 | private createColumn (table: Table, column: TableColumn) { 319 | const op: Operations.ColumnCreateOperation = { 320 | type: 'column.create', 321 | table: table.name, 322 | column: column.name, 323 | columnType: column.type, 324 | args: column.args, 325 | comment: column.comment, 326 | nullable: column.nullable, 327 | defaultValue: column.defaultValue, 328 | priority: 0, 329 | } 330 | this.operations.push(op) 331 | 332 | // Foreign key 333 | this.createForeignKey(table, column) 334 | } 335 | 336 | private renameColumn (table: Table, fromCol: TableColumn, toCol: TableColumn) { 337 | const op: Operations.ColumnRenameOperation = { 338 | type: 'column.rename', 339 | table: table.name, 340 | fromName: fromCol.name, 341 | toName: toCol.name, 342 | priority: 0, 343 | } 344 | this.operations.push(op) 345 | } 346 | 347 | private alterColumn (table: Table, column: TableColumn) { 348 | const op: Operations.ColumnAlterOperation = { 349 | type: 'column.alter', 350 | table: table.name, 351 | column: column.name, 352 | columnType: column.type, 353 | args: column.args, 354 | comment: column.comment, 355 | nullable: column.nullable, 356 | defaultValue: column.defaultValue, 357 | priority: 0, 358 | } 359 | this.operations.push(op) 360 | } 361 | 362 | private dropColumn (table: Table, column: TableColumn) { 363 | const op: Operations.ColumnDropOperation = { 364 | type: 'column.drop', 365 | table: table.name, 366 | column: column.name, 367 | priority: 0, 368 | } 369 | this.operations.push(op) 370 | } 371 | 372 | private compareIndex ( 373 | fromList: Array, 374 | toList: Array, 375 | create: (index: TableIndex | TableUnique) => void, 376 | drop: (index: TableIndex | TableUnique) => void, 377 | ) { 378 | const addIndexQueue = toList.slice() 379 | for (const fromIndex of fromList) { 380 | let removed = true 381 | for (let i = 0, l = addIndexQueue.length; i < l; i++) { 382 | const toIndex = addIndexQueue[i] 383 | if ( 384 | fromIndex.name === toIndex.name && 385 | // @ts-ignore 386 | fromIndex.type === toIndex.type && 387 | isEqual(fromIndex.columns.sort(), toIndex.columns.sort()) 388 | ) { 389 | removed = false 390 | addIndexQueue.splice(i, 1) 391 | break 392 | } 393 | } 394 | 395 | if (removed) { 396 | drop(fromIndex) 397 | } 398 | } 399 | for (const index of addIndexQueue) { 400 | create(index) 401 | } 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { migrate, MigrateOptions } from './migrate' 2 | export { generateAbstractDatabase } from './abstract/generateAbstractDatabase' 3 | export { computeDiff } from './diff/computeDiff' 4 | export { read } from './connector/read' 5 | export { write } from './connector/write' 6 | export { MigratePlugin, WriteParams } from './plugin/MigratePlugin' 7 | -------------------------------------------------------------------------------- /src/migrate.ts: -------------------------------------------------------------------------------- 1 | import { Config } from 'knex' 2 | import { GraphQLSchema } from 'graphql' 3 | import { read } from './connector/read' 4 | import { generateAbstractDatabase, ScalarMap, NameTransform } from './abstract/generateAbstractDatabase' 5 | import { computeDiff } from './diff/computeDiff' 6 | import { write } from './connector/write' 7 | import { MigratePlugin } from './plugin/MigratePlugin' 8 | import { Operation } from './diff/Operation' 9 | import { defaultNameTransform } from './util/defaultNameTransforms' 10 | 11 | export interface MigrateOptions { 12 | /** 13 | * Table schema: `.`. 14 | */ 15 | dbSchemaName?: string 16 | /** 17 | * Table name prefix: ``. 18 | */ 19 | dbTablePrefix?: string 20 | /** 21 | * Column name prefix: ``. 22 | */ 23 | dbColumnPrefix?: string 24 | /** 25 | * Overwrite table and column comments (not supported in some databases). 26 | */ 27 | updateComments?: boolean 28 | /** 29 | * Transform the table names. 30 | */ 31 | transformTableName?: NameTransform | null 32 | /** 33 | * Transform the column names. 34 | */ 35 | transformColumnName?: NameTransform | null 36 | /** 37 | * Custom Scalar mapping 38 | */ 39 | scalarMap?: ScalarMap | null 40 | /** 41 | * Map scalar/enum lists to json column type by default. 42 | */ 43 | mapListToJson?: boolean 44 | /** 45 | * List of graphql-migrate plugins 46 | */ 47 | plugins?: MigratePlugin[], 48 | /** 49 | * Display debug information 50 | */ 51 | debug?: boolean 52 | } 53 | 54 | export const defaultOptions: MigrateOptions = { 55 | dbSchemaName: 'public', 56 | dbTablePrefix: '', 57 | dbColumnPrefix: '', 58 | updateComments: false, 59 | transformTableName: defaultNameTransform, 60 | transformColumnName: defaultNameTransform, 61 | scalarMap: null, 62 | mapListToJson: true, 63 | plugins: [], 64 | debug: false, 65 | } 66 | 67 | export async function migrate ( 68 | config: Config, 69 | schema: GraphQLSchema, 70 | options: MigrateOptions = {}, 71 | ): Promise { 72 | // Default options 73 | const finalOptions = { 74 | ...defaultOptions, 75 | ...options, 76 | } 77 | if (finalOptions.debug) { 78 | config = { 79 | ...config, 80 | debug: true, 81 | } 82 | } 83 | // Read current 84 | const existingAdb = await read( 85 | config, 86 | finalOptions.dbSchemaName, 87 | finalOptions.dbTablePrefix, 88 | finalOptions.dbColumnPrefix, 89 | ) 90 | // Generate new 91 | const newAdb = await generateAbstractDatabase(schema, { 92 | transformTableName: finalOptions.transformTableName, 93 | transformColumnName: finalOptions.transformColumnName, 94 | scalarMap: finalOptions.scalarMap, 95 | }) 96 | if (finalOptions.debug) { 97 | console.log('BEFORE', JSON.stringify(existingAdb.tables, null, 2)) 98 | console.log('AFTER', JSON.stringify(newAdb.tables, null, 2)) 99 | } 100 | // Diff 101 | const ops = await computeDiff(existingAdb, newAdb, { 102 | updateComments: finalOptions.updateComments, 103 | }) 104 | if (finalOptions.debug) { 105 | console.log('OPERATIONS', ops) 106 | } 107 | // Write back to DB 108 | await write( 109 | ops, 110 | config, 111 | finalOptions.dbSchemaName, 112 | finalOptions.dbTablePrefix, 113 | finalOptions.dbColumnPrefix, 114 | finalOptions.plugins, 115 | ) 116 | 117 | return ops 118 | } 119 | -------------------------------------------------------------------------------- /src/plugin/MigratePlugin.ts: -------------------------------------------------------------------------------- 1 | import { OperationType } from '../diff/Operation' 2 | import { Transaction } from 'knex' 3 | 4 | export type WriteCallback = (op: any, transaction: Transaction) => any 5 | 6 | export interface WriteParams { 7 | tap: ( 8 | type: OperationType, 9 | event: 'before' | 'after', 10 | callback: WriteCallback, 11 | ) => void 12 | } 13 | 14 | export abstract class MigratePlugin { 15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 16 | public write (params: WriteParams): void { 17 | // Re-implement 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/util/comments.ts: -------------------------------------------------------------------------------- 1 | export function escapeComment (comment: string | null) { 2 | return comment ? comment.replace(/'/g, `''`).replace(/\n\s*/g, '\n').trim() : null 3 | } 4 | -------------------------------------------------------------------------------- /src/util/defaultNameTransforms.ts: -------------------------------------------------------------------------------- 1 | import Case from 'case' 2 | import { NameTransformDirection } from 'src/abstract/generateAbstractDatabase' 3 | 4 | export function defaultNameTransform (name: string, direction: NameTransformDirection) { 5 | if (direction === 'to-db') { 6 | return Case.snake(name) 7 | } 8 | return name 9 | } 10 | -------------------------------------------------------------------------------- /src/util/getCheckConstraints.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | 3 | const queries: any = { 4 | pg: (knex: Knex, tableName: string, schemaName: string) => ({ 5 | sql: `select 6 | c.conname as "indexName", 7 | array_to_string(array_agg(a.attname), ',') as "columnNames", 8 | c.consrc as "expression" 9 | from 10 | pg_class t, 11 | pg_constraint c, 12 | pg_namespace n, 13 | pg_attribute a 14 | where 15 | c.conrelid = t.oid 16 | and n.oid = c.connamespace 17 | and a.attrelid = t.oid 18 | and a.attnum = ANY(c.conkey) 19 | and c.contype = 'c' 20 | and t.relname = ? 21 | and n.nspname = ? 22 | group by 23 | t.relname, 24 | c.conname, 25 | c.consrc;`, 26 | bindings: [tableName, schemaName], 27 | output: (resp: any) => resp.rows.map((row: any) => { 28 | let values = null 29 | const result = /\(ARRAY\[(.+)\]\)/.exec(row.expression) 30 | if (result) { 31 | values = result[1].split(',').map((str: string) => { 32 | const resultItem = /'(.+)'::text/.exec(str) 33 | if (resultItem) { 34 | return resultItem[1] 35 | } 36 | return str.trim() 37 | }) 38 | } 39 | 40 | return { 41 | ...row, 42 | columnNames: row.columnNames.split(','), 43 | values, 44 | } 45 | }), 46 | }), 47 | } 48 | 49 | export default async function ( 50 | knex: Knex, 51 | tableName: string, 52 | schemaName: string, 53 | ): Promise> { 54 | const query = queries[knex.client.config.client] 55 | if (!query) { 56 | console.warn(`${knex.client.config.client} column unique constraints not supported`) 57 | return [] 58 | } 59 | const { sql, bindings, output } = query(knex, tableName, schemaName) 60 | const resp = await knex.raw(sql, bindings) 61 | return output(resp) 62 | } 63 | -------------------------------------------------------------------------------- /src/util/getColumnComments.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | 3 | const queries: any = { 4 | pg: (knex: Knex, tableName: string, schemaName: string) => ({ 5 | sql: `SELECT a.attname as column, 6 | pg_catalog.col_description(a.attrelid, a.attnum) as comment 7 | FROM pg_catalog.pg_attribute a 8 | WHERE a.attrelid = (SELECT c.oid 9 | FROM pg_catalog.pg_class c 10 | LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace 11 | WHERE c.relname = ? 12 | AND n.nspname = ?) AND a.attnum > 0 AND NOT a.attisdropped;`, 13 | bindings: [tableName, schemaName], 14 | output: (resp: any) => resp.rows, 15 | }), 16 | } 17 | 18 | export default async function ( 19 | knex: Knex, 20 | tableName: string, 21 | schemaName: string, 22 | ): Promise> { 23 | const query = queries[knex.client.config.client] 24 | if (!query) { 25 | console.warn(`${knex.client.config.client} doesn't support column comment`) 26 | return [] 27 | } 28 | const { sql, bindings, output } = query(knex, tableName, schemaName) 29 | const resp = await knex.raw(sql, bindings) 30 | return output(resp) 31 | } 32 | -------------------------------------------------------------------------------- /src/util/getForeignKeys.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | 3 | const queries: any = { 4 | pg: (knex: Knex, tableName: string, schemaName: string) => ({ 5 | sql: `SELECT 6 | kcu.column_name as "column", 7 | ccu.table_name AS "foreignTable", 8 | ccu.column_name AS "foreignColumn" 9 | FROM 10 | information_schema.table_constraints AS tc 11 | JOIN information_schema.key_column_usage AS kcu 12 | ON tc.constraint_name = kcu.constraint_name 13 | AND tc.table_schema = kcu.table_schema 14 | JOIN information_schema.constraint_column_usage AS ccu 15 | ON ccu.constraint_name = tc.constraint_name 16 | AND ccu.table_schema = tc.table_schema 17 | where tc.constraint_type = 'FOREIGN KEY' and tc.table_name = ? and tc.table_schema = ?;`, 18 | bindings: [tableName, schemaName], 19 | output: (resp: any) => resp.rows, 20 | }), 21 | } 22 | 23 | export default async function ( 24 | knex: Knex, 25 | tableName: string, 26 | schemaName: string, 27 | ): Promise> { 28 | const query = queries[knex.client.config.client] 29 | if (!query) { 30 | console.warn(`${knex.client.config.client} foreign keys not supported`) 31 | return [] 32 | } 33 | const { sql, bindings, output } = query(knex, tableName, schemaName) 34 | const resp = await knex.raw(sql, bindings) 35 | return output(resp) 36 | } 37 | -------------------------------------------------------------------------------- /src/util/getIndexes.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | 3 | const queries: any = { 4 | pg: (knex: Knex, tableName: string, schemaName: string) => ({ 5 | sql: `select 6 | i.relname as "indexName", 7 | array_to_string(array_agg(a.attname), ',') as "columnNames", 8 | null as "type" 9 | from 10 | pg_class t, 11 | pg_class i, 12 | pg_index ix, 13 | pg_attribute a, 14 | pg_namespace n 15 | where 16 | t.oid = ix.indrelid 17 | and i.oid = ix.indexrelid 18 | and a.attrelid = t.oid 19 | and a.attnum = ANY(ix.indkey) 20 | and t.relkind = 'r' 21 | and t.relname = ? 22 | and t.relnamespace = n.oid 23 | and n.nspname = ? 24 | group by 25 | t.relname, 26 | i.relname 27 | order by 28 | t.relname, 29 | i.relname;`, 30 | bindings: [tableName, schemaName], 31 | output: (resp: any) => resp.rows.map((row: any) => ({ 32 | ...row, 33 | columnNames: row.columnNames.split(','), 34 | })), 35 | }), 36 | } 37 | 38 | export default async function ( 39 | knex: Knex, 40 | tableName: string, 41 | schemaName: string, 42 | ): Promise> { 43 | const query = queries[knex.client.config.client] 44 | if (!query) { 45 | console.warn(`${knex.client.config.client} column index not supported`) 46 | return [] 47 | } 48 | const { sql, bindings, output } = query(knex, tableName, schemaName) 49 | const resp = await knex.raw(sql, bindings) 50 | return output(resp) 51 | } 52 | -------------------------------------------------------------------------------- /src/util/getPrimaryKey.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | 3 | const queries: any = { 4 | pg: (knex: Knex, tableName: string, schemaName: string) => ({ 5 | sql: `SELECT 6 | c.column_name as column, 7 | tc.constraint_name as "indexName" 8 | FROM information_schema.table_constraints tc 9 | JOIN information_schema.constraint_column_usage ccu USING (constraint_schema, constraint_name) 10 | JOIN information_schema.columns c ON c.table_schema = tc.constraint_schema 11 | AND tc.table_name = c.table_name AND ccu.column_name = c.column_name 12 | where tc.constraint_type = 'PRIMARY KEY' and tc.table_name = ? and tc.table_schema = ?;`, 13 | bindings: [tableName, schemaName], 14 | output: (resp: any) => resp.rows, 15 | }), 16 | } 17 | 18 | export default async function ( 19 | knex: Knex, 20 | tableName: string, 21 | schemaName: string, 22 | ): Promise> { 23 | const query = queries[knex.client.config.client] 24 | if (!query) { 25 | console.warn(`${knex.client.config.client} primary keys not supported`) 26 | return [] 27 | } 28 | const { sql, bindings, output } = query(knex, tableName, schemaName) 29 | const resp = await knex.raw(sql, bindings) 30 | return output(resp) 31 | } 32 | -------------------------------------------------------------------------------- /src/util/getTypeAlias.ts: -------------------------------------------------------------------------------- 1 | const ALIAS: any = { 2 | 'int': { type: 'integer', args: [] }, 3 | 'int4': { type: 'integer', args: [] }, 4 | 'smallint': { type: 'integer', args: [] }, 5 | 'bigint': { type: 'bigInteger', args: [] }, 6 | 'character varying': { type: 'string', args: [] }, 7 | 'varchar': { type: 'string', args: [] }, 8 | 'double precision': { type: 'float', args: [8] }, 9 | 'float8': { type: 'float', args: [8] }, 10 | 'real': { type: 'float', args: [4] }, 11 | 'float4': { type: 'float', args: [4] }, 12 | 'bool': { type: 'boolean', args: [] }, 13 | 'time without time zone': { type: 'time', args: [] }, 14 | 'time with time zone': { type: 'time', args: [] }, 15 | 'timestamp without time zone': { type: 'timestamp', args: [false] }, 16 | 'timestamp with time zone': { type: 'timestamp', args: [true] }, 17 | 'timestamptz': { type: 'timestamp', args: [true] }, 18 | 'bytea': { type: 'binary', args: [] }, 19 | } 20 | 21 | export default function (dataType: string, maxLength: any): { type: string, args: any[] } { 22 | let alias = ALIAS[dataType.toLowerCase()] 23 | if (!alias) { 24 | alias = { type: dataType, args: [] } 25 | } 26 | if (alias.type === 'string' && maxLength) { alias.args = [maxLength] } 27 | return alias 28 | } 29 | -------------------------------------------------------------------------------- /src/util/getUniques.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | 3 | const queries: any = { 4 | pg: (knex: Knex, tableName: string, schemaName: string) => ({ 5 | sql: `select 6 | c.conname as "indexName", 7 | array_to_string(array_agg(a.attname), ',') as "columnNames" 8 | from 9 | pg_class t, 10 | pg_constraint c, 11 | pg_namespace n, 12 | pg_attribute a 13 | where 14 | c.conrelid = t.oid 15 | and n.oid = c.connamespace 16 | and a.attrelid = t.oid 17 | and a.attnum = ANY(c.conkey) 18 | and c.contype = 'u' 19 | and t.relname = ? 20 | and n.nspname = ? 21 | group by 22 | t.relname, 23 | c.conname;`, 24 | bindings: [tableName, schemaName], 25 | output: (resp: any) => resp.rows.map((row: any) => ({ 26 | ...row, 27 | columnNames: row.columnNames.split(','), 28 | })), 29 | }), 30 | } 31 | 32 | export default async function ( 33 | knex: Knex, 34 | tableName: string, 35 | schemaName: string, 36 | ): Promise> { 37 | const query = queries[knex.client.config.client] 38 | if (!query) { 39 | console.warn(`${knex.client.config.client} column unique constraints not supported`) 40 | return [] 41 | } 42 | const { sql, bindings, output } = query(knex, tableName, schemaName) 43 | const resp = await knex.raw(sql, bindings) 44 | return output(resp) 45 | } 46 | -------------------------------------------------------------------------------- /src/util/listTables.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | 3 | const queries: any = { 4 | mssql: (knex: Knex, schemaName: string) => ({ 5 | sql: `select table_name from information_schema.tables 6 | where table_type = 'BASE TABLE' and table_schema = ? and table_catalog = ?`, 7 | bindings: [schemaName, knex.client.database()], 8 | output: (resp: any) => resp.rows.map((table: any) => ({ name: table.table_name })), 9 | }), 10 | 11 | mysql: (knex: Knex) => ({ 12 | sql: `select table_name, table_comment from information_schema.tables where table_schema = ?`, 13 | bindings: [knex.client.database()], 14 | output: (resp: any) => resp.map((table: any) => ({ name: table.table_name, comment: table.table_comment })), 15 | }), 16 | 17 | mysql2: (knex: Knex) => ({ 18 | sql: `select table_name, table_comment from information_schema.tables where table_schema = ?`, 19 | bindings: [knex.client.database()], 20 | output: (resp: any) => resp.map((table: any) => ({ name: table.table_name, comment: table.table_comment })), 21 | }), 22 | 23 | oracle: () => ({ 24 | sql: `select table_name from user_tables`, 25 | output: (resp: any) => resp.map((table: any) => ({ name: table.TABLE_NAME })), 26 | }), 27 | 28 | pg: (knex: Knex, schemaName: string) => ({ 29 | sql: `select c.relname, d.description from pg_class c 30 | left join pg_namespace n on n.oid = c.relnamespace 31 | left join pg_description d on d.objoid = c.oid and d.objsubid = 0 32 | where c.relkind = 'r' and n.nspname = ?;`, 33 | bindings: [schemaName], 34 | output: (resp: any) => resp.rows.map((table: any) => ({ name: table.relname, comment: table.description })), 35 | }), 36 | 37 | sqlite3: () => ({ 38 | sql: `SELECT name FROM sqlite_master WHERE type='table';`, 39 | output: (resp: any) => resp.map((table: any) => ({ name: table.name })), 40 | }), 41 | } 42 | 43 | export default async function (knex: Knex, schemaName: string) { 44 | const query = queries[knex.client.config.client] 45 | if (!query) { 46 | console.error(`Client ${knex.client.config.client} not supported`) 47 | } 48 | const { sql, bindings, output } = query(knex, schemaName) 49 | const resp = await knex.raw(sql, bindings) 50 | return output(resp) 51 | } 52 | -------------------------------------------------------------------------------- /src/util/sortOps.ts: -------------------------------------------------------------------------------- 1 | import { Operation, OperationType } from '../diff/Operation' 2 | 3 | const priority: OperationType[] = [ 4 | 'table.foreign.drop', 5 | 'table.unique.drop', 6 | 'table.index.drop', 7 | 'column.drop', 8 | 'table.drop', 9 | 'table.create', 10 | 'column.create', 11 | 'table.foreign.create', 12 | ] 13 | 14 | function getPriority (op: Operation) { 15 | const index = priority.indexOf(op.type) 16 | if (index === -1) { 17 | return 999 18 | } 19 | return index 20 | } 21 | 22 | export function sortOps (a: Operation, b: Operation): number { 23 | if (a.type === b.type) { 24 | return a.priority - b.priority 25 | } 26 | return getPriority(a) - getPriority(b) 27 | } 28 | -------------------------------------------------------------------------------- /src/util/transformDefaultValue.ts: -------------------------------------------------------------------------------- 1 | export default function (value: any) { 2 | if (value === 'NULL::character varying') { return null } 3 | return value 4 | } 5 | -------------------------------------------------------------------------------- /tests/specs/abstract-database.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'graphql' 2 | import { generateAbstractDatabase } from '../../src' 3 | 4 | describe('create abstract database', () => { 5 | test('skip root types', async () => { 6 | const schema = buildSchema(` 7 | type Query { 8 | hello: String 9 | } 10 | 11 | type Mutation { 12 | do: Boolean 13 | } 14 | 15 | type Subscription { 16 | notif: String 17 | } 18 | `) 19 | const adb = await generateAbstractDatabase(schema) 20 | expect(adb.tables.length).toBe(0) 21 | }) 22 | 23 | test('simple type', async () => { 24 | const schema = buildSchema(` 25 | """ 26 | A user. 27 | """ 28 | type User { 29 | id: ID! 30 | """ 31 | Display name. 32 | """ 33 | name: String! 34 | } 35 | `) 36 | const adb = await generateAbstractDatabase(schema) 37 | expect(adb.tables.length).toBe(1) 38 | const [User] = adb.tables 39 | expect(User.name).toBe('user') 40 | expect(User.comment).toBe('A user.') 41 | expect(User.columns.length).toBe(2) 42 | const [colId, colName] = User.columns 43 | expect(colId.name).toBe('id') 44 | expect(colId.type).toBe('uuid') 45 | expect(colName.name).toBe('name') 46 | expect(colName.type).toBe('string') 47 | expect(colName.comment).toBe('Display name.') 48 | }) 49 | 50 | test('skip table', async () => { 51 | const schema = buildSchema(` 52 | """ 53 | @db.skip 54 | """ 55 | type User { 56 | id: ID! 57 | name: String! 58 | } 59 | `) 60 | const adb = await generateAbstractDatabase(schema) 61 | expect(adb.tables.length).toBe(0) 62 | }) 63 | 64 | test('skip field', async () => { 65 | const schema = buildSchema(` 66 | type User { 67 | id: ID! 68 | """ 69 | @db.skip 70 | """ 71 | name: String! 72 | } 73 | `) 74 | const adb = await generateAbstractDatabase(schema) 75 | expect(adb.tables.length).toBe(1) 76 | const [User] = adb.tables 77 | expect(User.name).toBe('user') 78 | expect(User.columns.length).toBe(1) 79 | const [colId] = User.columns 80 | expect(colId.name).toBe('id') 81 | }) 82 | 83 | test('not null', async () => { 84 | const schema = buildSchema(` 85 | type User { 86 | name: String! 87 | nickname: String 88 | } 89 | `) 90 | const adb = await generateAbstractDatabase(schema) 91 | expect(adb.tables.length).toBe(1) 92 | const [User] = adb.tables 93 | expect(User.columns.length).toBe(2) 94 | const [colName, colNickname] = User.columns 95 | expect(colName.nullable).toBe(false) 96 | expect(colNickname.nullable).toBe(true) 97 | }) 98 | 99 | test('default value', async () => { 100 | const schema = buildSchema(` 101 | type User { 102 | """ 103 | @db.default: true 104 | """ 105 | someOption: Boolean 106 | """ 107 | @db.default: false 108 | """ 109 | thatOption: Boolean 110 | """ 111 | @db.default: '' 112 | """ 113 | thisOption: Boolean 114 | } 115 | `) 116 | const adb = await generateAbstractDatabase(schema) 117 | const [User] = adb.tables 118 | const [colSomeOption, colThatOption, colThisOption] = User.columns 119 | expect(colSomeOption.defaultValue).toBe(true) 120 | expect(colThatOption.defaultValue).toBe(false) 121 | expect(colThisOption.defaultValue).toBe('') 122 | }) 123 | 124 | test('default primary index', async () => { 125 | const schema = buildSchema(` 126 | type User { 127 | """ 128 | This will get a primary index 129 | """ 130 | id: ID! 131 | email: String! 132 | } 133 | `) 134 | const adb = await generateAbstractDatabase(schema) 135 | expect(adb.tables.length).toBe(1) 136 | const [User] = adb.tables 137 | expect(User.primaries.length).toBe(1) 138 | const [id] = User.primaries 139 | expect(id.columns).toEqual(['id']) 140 | }) 141 | 142 | test('no default primary index', async () => { 143 | const schema = buildSchema(` 144 | type User { 145 | """ 146 | This will NOT get a primary index 147 | """ 148 | foo: ID! 149 | """ 150 | Neither will this 151 | """ 152 | id: String! 153 | } 154 | `) 155 | const adb = await generateAbstractDatabase(schema) 156 | const [User] = adb.tables 157 | expect(User.primaries.length).toBe(0) 158 | }) 159 | 160 | test('skip default primary index', async () => { 161 | const schema = buildSchema(` 162 | type User { 163 | """ 164 | @db.primary: false 165 | """ 166 | id: ID! 167 | email: String! 168 | } 169 | `) 170 | const adb = await generateAbstractDatabase(schema) 171 | expect(adb.tables.length).toBe(1) 172 | const [User] = adb.tables 173 | expect(User.primaries.length).toBe(0) 174 | }) 175 | 176 | test('change primary index', async () => { 177 | const schema = buildSchema(` 178 | type User { 179 | id: ID! 180 | """ 181 | @db.primary 182 | """ 183 | email: String! 184 | } 185 | `) 186 | const adb = await generateAbstractDatabase(schema) 187 | expect(adb.tables.length).toBe(1) 188 | const [User] = adb.tables 189 | expect(User.primaries.length).toBe(1) 190 | const [email] = User.primaries 191 | expect(email.columns).toEqual(['email']) 192 | }) 193 | 194 | test('simple index', async () => { 195 | const schema = buildSchema(` 196 | type User { 197 | id: ID! 198 | """ 199 | @db.index 200 | """ 201 | email: String! 202 | } 203 | `) 204 | const adb = await generateAbstractDatabase(schema) 205 | expect(adb.tables.length).toBe(1) 206 | const [User] = adb.tables 207 | expect(User.indexes.length).toBe(1) 208 | const [email] = User.indexes 209 | expect(email.columns).toEqual(['email']) 210 | }) 211 | 212 | test('multiple indexes', async () => { 213 | const schema = buildSchema(` 214 | type User { 215 | """ 216 | @db.index 217 | """ 218 | id: String! 219 | """ 220 | @db.index 221 | """ 222 | email: String! 223 | } 224 | `) 225 | const adb = await generateAbstractDatabase(schema) 226 | expect(adb.tables.length).toBe(1) 227 | const [User] = adb.tables 228 | expect(User.indexes.length).toBe(2) 229 | const [id, email] = User.indexes 230 | expect(id.columns).toEqual(['id']) 231 | expect(email.columns).toEqual(['email']) 232 | }) 233 | 234 | test('named index', async () => { 235 | const schema = buildSchema(` 236 | type User { 237 | """ 238 | @db.index: 'myIndex' 239 | """ 240 | email: String! 241 | """ 242 | @db.index: 'myIndex' 243 | """ 244 | name: String! 245 | } 246 | `) 247 | const adb = await generateAbstractDatabase(schema) 248 | expect(adb.tables.length).toBe(1) 249 | const [User] = adb.tables 250 | expect(User.indexes.length).toBe(1) 251 | const [myIndex] = User.indexes 252 | expect(myIndex.name).toBe('myIndex') 253 | expect(myIndex.columns).toEqual(['email', 'name']) 254 | }) 255 | 256 | test('object index', async () => { 257 | const schema = buildSchema(` 258 | type User { 259 | """ 260 | @db.index: { name: 'myIndex', type: 'string' } 261 | """ 262 | email: String! 263 | """ 264 | @db.index: 'myIndex' 265 | """ 266 | name: String! 267 | } 268 | `) 269 | const adb = await generateAbstractDatabase(schema) 270 | expect(adb.tables.length).toBe(1) 271 | const [User] = adb.tables 272 | expect(User.indexes.length).toBe(1) 273 | const [myIndex] = User.indexes 274 | expect(myIndex.name).toBe('myIndex') 275 | expect(myIndex.type).toBe('string') 276 | expect(myIndex.columns).toEqual(['email', 'name']) 277 | }) 278 | 279 | test('unique index', async () => { 280 | const schema = buildSchema(` 281 | type User { 282 | id: ID! 283 | """ 284 | @db.unique 285 | """ 286 | email: String! 287 | } 288 | `) 289 | const adb = await generateAbstractDatabase(schema) 290 | expect(adb.tables.length).toBe(1) 291 | const [User] = adb.tables 292 | expect(User.uniques.length).toBe(1) 293 | const [email] = User.uniques 294 | expect(email.columns).toEqual(['email']) 295 | }) 296 | 297 | test('custom name', async () => { 298 | const schema = buildSchema(` 299 | """ 300 | @db.name: 'people' 301 | """ 302 | type User { 303 | id: ID! 304 | } 305 | `) 306 | const adb = await generateAbstractDatabase(schema) 307 | expect(adb.tables.length).toBe(1) 308 | const [User] = adb.tables 309 | expect(User.annotations.name).toBe('people') 310 | expect(User.name).toBe('people') 311 | }) 312 | 313 | test('custom type', async () => { 314 | const schema = buildSchema(` 315 | type User { 316 | """ 317 | @db.type: 'string' 318 | @db.length: 36 319 | """ 320 | id: ID! 321 | } 322 | `) 323 | const adb = await generateAbstractDatabase(schema) 324 | expect(adb.tables.length).toBe(1) 325 | const [User] = adb.tables 326 | const [colId] = User.columns 327 | expect(colId.name).toBe('id') 328 | expect(colId.annotations.type).toBe('string') 329 | expect(colId.annotations.length).toBe(36) 330 | expect(colId.type).toBe('string') 331 | expect(colId.args).toEqual([36]) 332 | }) 333 | 334 | test('foreign key', async () => { 335 | const schema = buildSchema(` 336 | type User { 337 | id: ID! 338 | messages: [Message!]! 339 | } 340 | 341 | type Message { 342 | id: ID! 343 | user: User 344 | } 345 | `) 346 | const adb = await generateAbstractDatabase(schema) 347 | expect(adb.tables.length).toBe(2) 348 | const [User, Message] = adb.tables 349 | expect(User.name).toBe('user') 350 | expect(Message.name).toBe('message') 351 | expect(User.columns.length).toBe(1) 352 | expect(Message.columns.length).toBe(2) 353 | const [colId, colUserForeign] = Message.columns 354 | expect(colId.name).toBe('id') 355 | expect(colUserForeign.name).toBe('user_foreign') 356 | expect(colUserForeign.type).toBe('uuid') 357 | expect(colUserForeign.foreign && colUserForeign.foreign.tableName).toBe('user') 358 | expect(colUserForeign.foreign && colUserForeign.foreign.columnName).toBe('id') 359 | }) 360 | 361 | test('many to many', async () => { 362 | const schema = buildSchema(` 363 | type User { 364 | id: ID! 365 | """ 366 | @db.manyToMany: 'users' 367 | """ 368 | messages: [Message!]! 369 | } 370 | 371 | type Message { 372 | id: ID! 373 | """ 374 | @db.manyToMany: 'messages' 375 | """ 376 | users: [User] 377 | } 378 | `) 379 | const adb = await generateAbstractDatabase(schema) 380 | expect(adb.tables.length).toBe(3) 381 | const Join = adb.tables[2] 382 | expect(Join.name).toBe('message_users_join_user_messages') 383 | const [colMessageUsers, colUserMessages] = Join.columns 384 | expect(colMessageUsers.name).toBe('users_foreign') 385 | expect(colMessageUsers.type).toBe('uuid') 386 | expect(colMessageUsers.foreign && colMessageUsers.foreign.tableName).toBe('message') 387 | expect(colMessageUsers.foreign && colMessageUsers.foreign.columnName).toBe('id') 388 | expect(colUserMessages.name).toBe('messages_foreign') 389 | expect(colUserMessages.type).toBe('uuid') 390 | expect(colUserMessages.foreign && colUserMessages.foreign.tableName).toBe('user') 391 | expect(colUserMessages.foreign && colUserMessages.foreign.columnName).toBe('id') 392 | }) 393 | 394 | test('many to many on self', async () => { 395 | const schema = buildSchema(` 396 | type User { 397 | id: ID! 398 | contacts: [User] 399 | } 400 | `) 401 | const adb = await generateAbstractDatabase(schema) 402 | expect(adb.tables.length).toBe(2) 403 | const [User, UserContacts] = adb.tables 404 | expect(UserContacts.name).toBe('user_contacts_join_user_contacts') 405 | expect(User.name).toBe('user') 406 | expect(User.columns.length).toBe(1) 407 | const [col1, col2] = UserContacts.columns 408 | expect(col1.name).toBe('id_foreign') 409 | expect(col1.type).toBe('uuid') 410 | expect(col1.foreign && col1.foreign.tableName).toBe('user') 411 | expect(col1.foreign && col1.foreign.columnName).toBe('id') 412 | expect(col2.name).toBe('id_foreign_other') 413 | expect(col2.type).toBe('uuid') 414 | expect(col2.foreign && col2.foreign.tableName).toBe('user') 415 | expect(col2.foreign && col2.foreign.columnName).toBe('id') 416 | }) 417 | 418 | test('simple list', async () => { 419 | const schema = buildSchema(` 420 | type User { 421 | id: ID! 422 | """ 423 | @db.type: 'json' 424 | """ 425 | names: [String] 426 | } 427 | `) 428 | const adb = await generateAbstractDatabase(schema) 429 | expect(adb.tables.length).toBe(1) 430 | const [User] = adb.tables 431 | expect(User.name).toBe('user') 432 | expect(User.columns.length).toBe(2) 433 | const [colId, colNames] = User.columns 434 | expect(colId.name).toBe('id') 435 | expect(colId.type).toBe('uuid') 436 | expect(colNames.name).toBe('names') 437 | expect(colNames.type).toBe('json') 438 | }) 439 | 440 | test('custom scalar map', async () => { 441 | const schema = buildSchema(` 442 | type User { 443 | name: String 444 | nickname: String 445 | } 446 | `) 447 | const adb = await generateAbstractDatabase(schema, { 448 | scalarMap: (field) => { 449 | if (field.name === 'name') { 450 | return { 451 | type: 'text', 452 | args: [], 453 | } 454 | } 455 | return null 456 | }, 457 | }) 458 | expect(adb.tables.length).toBe(1) 459 | const [User] = adb.tables 460 | expect(User.columns.length).toBe(2) 461 | const [colName, colNickname] = User.columns 462 | expect(colName.type).toBe('text') 463 | expect(colNickname.type).toBe('string') 464 | }) 465 | 466 | test('map lists to json', async () => { 467 | const schema = buildSchema(` 468 | type User { 469 | names: [String] 470 | } 471 | `) 472 | const adb = await generateAbstractDatabase(schema, { 473 | mapListToJson: true, 474 | }) 475 | expect(adb.tables.length).toBe(1) 476 | const [User] = adb.tables 477 | expect(User.columns.length).toBe(1) 478 | const [colNames] = User.columns 479 | expect(colNames.type).toBe('json') 480 | }) 481 | 482 | test('default name transforms', async () => { 483 | const schema = buildSchema(` 484 | type UserTeam { 485 | id: ID! 486 | name: String! 487 | yearlyBilling: Boolean! 488 | } 489 | `) 490 | const adb = await generateAbstractDatabase(schema) 491 | expect(adb.tables.length).toBe(1) 492 | const [UserTeam] = adb.tables 493 | expect(UserTeam.name).toBe('user_team') 494 | expect(UserTeam.columns.length).toBe(3) 495 | const [colId, colName, colYearlyBilling] = UserTeam.columns 496 | expect(colId.name).toBe('id') 497 | expect(colName.name).toBe('name') 498 | expect(colYearlyBilling.name).toBe('yearly_billing') 499 | }) 500 | 501 | test('custom name transforms', async () => { 502 | const schema = buildSchema(` 503 | type UserTeam { 504 | id: ID! 505 | name: String! 506 | yearlyBilling: Boolean! 507 | } 508 | `) 509 | const adb = await generateAbstractDatabase(schema, { 510 | transformTableName: (name, direction) => { 511 | if (direction === 'to-db') { 512 | return `Foo${name}` 513 | } 514 | return name 515 | }, 516 | transformColumnName: (name, direction) => { 517 | if (direction === 'to-db') { 518 | return `bar_${name}` 519 | } 520 | return name 521 | }, 522 | }) 523 | expect(adb.tables.length).toBe(1) 524 | const [UserTeam] = adb.tables 525 | expect(UserTeam.name).toBe('FooUserTeam') 526 | expect(UserTeam.columns.length).toBe(3) 527 | const [colId, colName, colYearlyBilling] = UserTeam.columns 528 | expect(colId.name).toBe('bar_id') 529 | expect(colName.name).toBe('bar_name') 530 | expect(colYearlyBilling.name).toBe('bar_yearlyBilling') 531 | }) 532 | 533 | test('sandbox', async () => { 534 | const schema = buildSchema(` 535 | scalar Date 536 | 537 | """ 538 | A user. 539 | """ 540 | type User { 541 | id: ID! 542 | """ 543 | Display name 544 | @db.length: 200 545 | """ 546 | name: String! 547 | email: String! 548 | score: Int 549 | """ 550 | @db.type: 'json' 551 | """ 552 | scores: [Int] 553 | messages: [Message] 554 | """ 555 | @db.manyToMany: 'users' 556 | """ 557 | sharedMessages: [Message] 558 | contacts: [User] 559 | } 560 | 561 | type Message { 562 | id: ID! 563 | user: User! 564 | """ 565 | @db.manyToMany: 'sharedMessages' 566 | """ 567 | users: [User] 568 | """ 569 | @db.type: 'datetime' 570 | """ 571 | created: Date! 572 | title: String! 573 | """ 574 | @db.type: 'text' 575 | """ 576 | content: String! 577 | } 578 | 579 | type Query { 580 | users: [User] 581 | } 582 | `) 583 | const adb = await generateAbstractDatabase(schema) 584 | expect(adb.tables.length).toBe(4) 585 | }) 586 | }) 587 | -------------------------------------------------------------------------------- /tests/specs/diff.ts: -------------------------------------------------------------------------------- 1 | import { computeDiff } from '../../src' 2 | import { AbstractDatabase } from '../../src/abstract/AbstractDatabase' 3 | import { Table } from '../../src/abstract/Table' 4 | import { TableColumn } from '../../src/abstract/TableColumn' 5 | 6 | function dbFactory (tables: Table[] = []): AbstractDatabase { 7 | return { 8 | tables, 9 | tableMap: new Map(), 10 | } 11 | } 12 | 13 | function tableFactory (options: any): Table { 14 | return { 15 | columns: [], 16 | primaries: [], 17 | indexes: [], 18 | uniques: [], 19 | annotations: {}, 20 | columnMap: new Map(), 21 | ...options, 22 | } 23 | } 24 | 25 | function columnFactory (options: any): TableColumn { 26 | return { 27 | args: [], 28 | nullable: true, 29 | annotations: {}, 30 | defaultValue: undefined, 31 | comment: null, 32 | foreign: null, 33 | ...options, 34 | } 35 | } 36 | 37 | describe('compute diff', () => { 38 | test('create simple table', async () => { 39 | const result = await computeDiff(dbFactory(), dbFactory([ 40 | tableFactory({ 41 | name: 'User', 42 | comment: 'Some comment', 43 | columns: [ 44 | columnFactory({ 45 | name: 'id', 46 | type: 'uuid', 47 | nullable: false, 48 | }), 49 | columnFactory({ 50 | name: 'name', 51 | type: 'string', 52 | args: [150], 53 | }), 54 | ], 55 | primaries: [ 56 | { columns: ['id'], name: undefined }, 57 | ], 58 | }), 59 | ])) 60 | expect(result.length).toBe(5) 61 | expect(result[0]).toEqual({ 62 | type: 'table.create', 63 | table: 'User', 64 | priority: 0, 65 | }) 66 | expect(result[1]).toEqual({ 67 | type: 'table.comment.set', 68 | table: 'User', 69 | comment: 'Some comment', 70 | priority: 0, 71 | }) 72 | expect(result[2]).toEqual({ 73 | type: 'column.create', 74 | table: 'User', 75 | column: 'id', 76 | columnType: 'uuid', 77 | args: [], 78 | nullable: false, 79 | defaultValue: undefined, 80 | comment: null, 81 | priority: 0, 82 | }) 83 | expect(result[3]).toEqual({ 84 | type: 'column.create', 85 | table: 'User', 86 | column: 'name', 87 | columnType: 'string', 88 | args: [150], 89 | nullable: true, 90 | defaultValue: undefined, 91 | comment: null, 92 | priority: 0, 93 | }) 94 | expect(result[4]).toEqual({ 95 | type: 'table.primary.set', 96 | table: 'User', 97 | columns: ['id'], 98 | priority: 0, 99 | }) 100 | }) 101 | 102 | test('rename table', async () => { 103 | const result = await computeDiff(dbFactory([ 104 | tableFactory({ 105 | name: 'User', 106 | }), 107 | ]), dbFactory([ 108 | tableFactory({ 109 | name: 'users', 110 | annotations: { 111 | oldNames: ['User'], 112 | }, 113 | }), 114 | ])) 115 | expect(result.length).toBe(1) 116 | expect(result[0]).toEqual({ 117 | type: 'table.rename', 118 | fromName: 'User', 119 | toName: 'users', 120 | priority: 0, 121 | }) 122 | }) 123 | 124 | test('update table comment', async () => { 125 | const result = await computeDiff(dbFactory([ 126 | tableFactory({ 127 | name: 'User', 128 | comment: 'Some comment', 129 | }), 130 | ]), dbFactory([ 131 | tableFactory({ 132 | name: 'User', 133 | comment: 'New comment', 134 | }), 135 | ]), { 136 | updateComments: true, 137 | }) 138 | expect(result.length).toBe(1) 139 | expect(result[0]).toEqual({ 140 | type: 'table.comment.set', 141 | table: 'User', 142 | comment: 'New comment', 143 | priority: 0, 144 | }) 145 | }) 146 | 147 | test('add column', async () => { 148 | const result = await computeDiff(dbFactory([ 149 | tableFactory({ 150 | name: 'User', 151 | }), 152 | ]), dbFactory([ 153 | tableFactory({ 154 | name: 'User', 155 | columns: [ 156 | columnFactory({ 157 | name: 'id', 158 | type: 'uuid', 159 | }), 160 | ], 161 | }), 162 | ])) 163 | expect(result.length).toBe(1) 164 | expect(result[0]).toEqual({ 165 | type: 'column.create', 166 | table: 'User', 167 | column: 'id', 168 | columnType: 'uuid', 169 | args: [], 170 | nullable: true, 171 | defaultValue: undefined, 172 | comment: null, 173 | priority: 0, 174 | }) 175 | }) 176 | 177 | test('add and remove column', async () => { 178 | const result = await computeDiff(dbFactory([ 179 | tableFactory({ 180 | name: 'User', 181 | columns: [ 182 | columnFactory({ 183 | name: 'id', 184 | type: 'uuid', 185 | }), 186 | ], 187 | }), 188 | ]), dbFactory([ 189 | tableFactory({ 190 | name: 'User', 191 | columns: [ 192 | columnFactory({ 193 | name: 'email', 194 | type: 'string', 195 | }), 196 | ], 197 | }), 198 | ])) 199 | expect(result.length).toBe(2) 200 | expect(result[0]).toEqual({ 201 | type: 'column.drop', 202 | table: 'User', 203 | column: 'id', 204 | priority: 0, 205 | }) 206 | expect(result[1]).toEqual({ 207 | type: 'column.create', 208 | table: 'User', 209 | column: 'email', 210 | columnType: 'string', 211 | args: [], 212 | nullable: true, 213 | defaultValue: undefined, 214 | comment: null, 215 | priority: 0, 216 | }) 217 | }) 218 | 219 | test('rename column', async () => { 220 | const result = await computeDiff(dbFactory([ 221 | tableFactory({ 222 | name: 'User', 223 | columns: [ 224 | columnFactory({ 225 | name: 'id', 226 | type: 'uuid', 227 | }), 228 | ], 229 | }), 230 | ]), dbFactory([ 231 | tableFactory({ 232 | name: 'User', 233 | columns: [ 234 | columnFactory({ 235 | name: 'email', 236 | type: 'uuid', 237 | annotations: { 238 | oldNames: ['id'], 239 | }, 240 | }), 241 | ], 242 | }), 243 | ])) 244 | expect(result.length).toBe(1) 245 | expect(result[0]).toEqual({ 246 | type: 'column.rename', 247 | table: 'User', 248 | fromName: 'id', 249 | toName: 'email', 250 | priority: 0, 251 | }) 252 | }) 253 | 254 | test('change column comment', async () => { 255 | const result = await computeDiff(dbFactory([ 256 | tableFactory({ 257 | name: 'User', 258 | columns: [ 259 | columnFactory({ 260 | name: 'id', 261 | type: 'uuid', 262 | comment: 'foo', 263 | }), 264 | ], 265 | }), 266 | ]), dbFactory([ 267 | tableFactory({ 268 | name: 'User', 269 | columns: [ 270 | columnFactory({ 271 | name: 'id', 272 | type: 'uuid', 273 | comment: 'bar', 274 | }), 275 | ], 276 | }), 277 | ]), { 278 | updateComments: true, 279 | }) 280 | expect(result.length).toBe(1) 281 | expect(result[0]).toEqual({ 282 | type: 'column.alter', 283 | table: 'User', 284 | column: 'id', 285 | columnType: 'uuid', 286 | args: [], 287 | nullable: true, 288 | defaultValue: undefined, 289 | comment: 'bar', 290 | priority: 0, 291 | }) 292 | }) 293 | 294 | test('change column type', async () => { 295 | const result = await computeDiff(dbFactory([ 296 | tableFactory({ 297 | name: 'User', 298 | columns: [ 299 | columnFactory({ 300 | name: 'id', 301 | type: 'uuid', 302 | }), 303 | ], 304 | }), 305 | ]), dbFactory([ 306 | tableFactory({ 307 | name: 'User', 308 | columns: [ 309 | columnFactory({ 310 | name: 'id', 311 | type: 'string', 312 | }), 313 | ], 314 | }), 315 | ])) 316 | expect(result.length).toBe(1) 317 | expect(result[0]).toEqual({ 318 | type: 'column.alter', 319 | table: 'User', 320 | column: 'id', 321 | columnType: 'string', 322 | args: [], 323 | nullable: true, 324 | defaultValue: undefined, 325 | comment: null, 326 | priority: 0, 327 | }) 328 | }) 329 | 330 | test('change column type args', async () => { 331 | const result = await computeDiff(dbFactory([ 332 | tableFactory({ 333 | name: 'User', 334 | columns: [ 335 | columnFactory({ 336 | name: 'id', 337 | type: 'string', 338 | args: [100], 339 | }), 340 | ], 341 | }), 342 | ]), dbFactory([ 343 | tableFactory({ 344 | name: 'User', 345 | columns: [ 346 | columnFactory({ 347 | name: 'id', 348 | type: 'string', 349 | args: [200], 350 | }), 351 | ], 352 | }), 353 | ])) 354 | expect(result.length).toBe(1) 355 | expect(result[0]).toEqual({ 356 | type: 'column.alter', 357 | table: 'User', 358 | column: 'id', 359 | columnType: 'string', 360 | args: [200], 361 | nullable: true, 362 | defaultValue: undefined, 363 | comment: null, 364 | priority: 0, 365 | }) 366 | }) 367 | 368 | test('change column nullable', async () => { 369 | const result = await computeDiff(dbFactory([ 370 | tableFactory({ 371 | name: 'User', 372 | columns: [ 373 | columnFactory({ 374 | name: 'id', 375 | type: 'string', 376 | nullable: false, 377 | }), 378 | ], 379 | }), 380 | ]), dbFactory([ 381 | tableFactory({ 382 | name: 'User', 383 | columns: [ 384 | columnFactory({ 385 | name: 'id', 386 | type: 'string', 387 | nullable: true, 388 | }), 389 | ], 390 | }), 391 | ])) 392 | expect(result.length).toBe(1) 393 | expect(result[0]).toEqual({ 394 | type: 'column.alter', 395 | table: 'User', 396 | column: 'id', 397 | columnType: 'string', 398 | args: [], 399 | nullable: true, 400 | defaultValue: undefined, 401 | comment: null, 402 | priority: 0, 403 | }) 404 | }) 405 | 406 | test('change column default value', async () => { 407 | const result = await computeDiff(dbFactory([ 408 | tableFactory({ 409 | name: 'User', 410 | columns: [ 411 | columnFactory({ 412 | name: 'id', 413 | type: 'string', 414 | defaultValue: 'foo', 415 | }), 416 | ], 417 | }), 418 | ]), dbFactory([ 419 | tableFactory({ 420 | name: 'User', 421 | columns: [ 422 | columnFactory({ 423 | name: 'id', 424 | type: 'string', 425 | defaultValue: 'bar', 426 | }), 427 | ], 428 | }), 429 | ])) 430 | expect(result.length).toBe(1) 431 | expect(result[0]).toEqual({ 432 | type: 'column.alter', 433 | table: 'User', 434 | column: 'id', 435 | columnType: 'string', 436 | args: [], 437 | nullable: true, 438 | defaultValue: 'bar', 439 | comment: null, 440 | priority: 0, 441 | }) 442 | }) 443 | 444 | test('change primary key', async () => { 445 | const result = await computeDiff(dbFactory([ 446 | tableFactory({ 447 | name: 'User', 448 | columns: [ 449 | columnFactory({ 450 | name: 'id', 451 | type: 'uuid', 452 | }), 453 | columnFactory({ 454 | name: 'email', 455 | type: 'string', 456 | }), 457 | ], 458 | primaries: [{ columns: ['id'] }], 459 | }), 460 | ]), dbFactory([ 461 | tableFactory({ 462 | name: 'User', 463 | columns: [ 464 | columnFactory({ 465 | name: 'id', 466 | type: 'uuid', 467 | }), 468 | columnFactory({ 469 | name: 'email', 470 | type: 'string', 471 | }), 472 | ], 473 | primaries: [{ columns: ['email'] }], 474 | }), 475 | ])) 476 | expect(result.length).toBe(1) 477 | expect(result[0]).toEqual({ 478 | type: 'table.primary.set', 479 | table: 'User', 480 | columns: ['email'], 481 | priority: 0, 482 | }) 483 | }) 484 | 485 | test('change anonymous index', async () => { 486 | const result = await computeDiff(dbFactory([ 487 | tableFactory({ 488 | name: 'User', 489 | columns: [ 490 | columnFactory({ 491 | name: 'id', 492 | type: 'uuid', 493 | }), 494 | columnFactory({ 495 | name: 'email', 496 | type: 'string', 497 | }), 498 | ], 499 | indexes: [{ columns: ['id'] }], 500 | }), 501 | ]), dbFactory([ 502 | tableFactory({ 503 | name: 'User', 504 | columns: [ 505 | columnFactory({ 506 | name: 'id', 507 | type: 'uuid', 508 | }), 509 | columnFactory({ 510 | name: 'email', 511 | type: 'string', 512 | }), 513 | ], 514 | indexes: [{ columns: ['email'] }], 515 | }), 516 | ])) 517 | expect(result.length).toBe(2) 518 | expect(result[0]).toEqual({ 519 | type: 'table.index.drop', 520 | table: 'User', 521 | columns: ['id'], 522 | priority: 0, 523 | }) 524 | expect(result[1]).toEqual({ 525 | type: 'table.index.create', 526 | table: 'User', 527 | columns: ['email'], 528 | priority: 0, 529 | }) 530 | }) 531 | 532 | test('change named index', async () => { 533 | const result = await computeDiff(dbFactory([ 534 | tableFactory({ 535 | name: 'User', 536 | columns: [ 537 | columnFactory({ 538 | name: 'id', 539 | type: 'uuid', 540 | }), 541 | columnFactory({ 542 | name: 'email', 543 | type: 'string', 544 | }), 545 | ], 546 | indexes: [{ columns: ['id'], name: 'foo' }], 547 | }), 548 | ]), dbFactory([ 549 | tableFactory({ 550 | name: 'User', 551 | columns: [ 552 | columnFactory({ 553 | name: 'id', 554 | type: 'uuid', 555 | }), 556 | columnFactory({ 557 | name: 'email', 558 | type: 'string', 559 | }), 560 | ], 561 | indexes: [{ columns: ['email'], name: 'foo' }], 562 | }), 563 | ])) 564 | expect(result.length).toBe(2) 565 | expect(result[0]).toEqual({ 566 | type: 'table.index.drop', 567 | table: 'User', 568 | columns: ['id'], 569 | indexName: 'foo', 570 | priority: 0, 571 | }) 572 | expect(result[1]).toEqual({ 573 | type: 'table.index.create', 574 | table: 'User', 575 | columns: ['email'], 576 | indexName: 'foo', 577 | priority: 0, 578 | }) 579 | }) 580 | 581 | test('untouched named index', async () => { 582 | const result = await computeDiff(dbFactory([ 583 | tableFactory({ 584 | name: 'User', 585 | columns: [ 586 | columnFactory({ 587 | name: 'id', 588 | type: 'uuid', 589 | }), 590 | columnFactory({ 591 | name: 'email', 592 | type: 'string', 593 | }), 594 | ], 595 | indexes: [{ columns: ['id'] }, { columns: ['id'], name: 'foo' }], 596 | }), 597 | ]), dbFactory([ 598 | tableFactory({ 599 | name: 'User', 600 | columns: [ 601 | columnFactory({ 602 | name: 'id', 603 | type: 'uuid', 604 | }), 605 | columnFactory({ 606 | name: 'email', 607 | type: 'string', 608 | }), 609 | ], 610 | indexes: [{ columns: ['id'], name: 'foo' }], 611 | }), 612 | ])) 613 | expect(result.length).toBe(1) 614 | expect(result[0]).toEqual({ 615 | type: 'table.index.drop', 616 | table: 'User', 617 | columns: ['id'], 618 | priority: 0, 619 | }) 620 | }) 621 | 622 | test('create table & join table', async () => { 623 | const result = await computeDiff(dbFactory(), dbFactory([ 624 | tableFactory({ 625 | name: 'user', 626 | }), 627 | tableFactory({ 628 | name: 'user_groups_join_group_users', 629 | }), 630 | ])) 631 | expect(result.length).toBe(2) 632 | expect(result[0]).toEqual({ 633 | type: 'table.create', 634 | table: 'user', 635 | priority: 0, 636 | }) 637 | expect(result[1]).toEqual({ 638 | type: 'table.create', 639 | table: 'user_groups_join_group_users', 640 | priority: 1, 641 | }) 642 | }) 643 | }) 644 | -------------------------------------------------------------------------------- /tests/specs/sortOps.ts: -------------------------------------------------------------------------------- 1 | import { sortOps } from '../../src/util/sortOps' 2 | import { Operation } from '../../src/diff/Operation' 3 | 4 | describe('sortOps', () => { 5 | test('sort ops by type priority', () => { 6 | const ops: Operation[] = [ 7 | { type: 'table.index.drop', priority: 0 }, 8 | { type: 'column.create', priority: 0 }, 9 | { type: 'table.unique.drop', priority: 0 }, 10 | { type: 'table.create', priority: 0 }, 11 | { type: 'table.drop', priority: 0 }, 12 | ] 13 | ops.sort(sortOps) 14 | expect(ops).toEqual([ 15 | { type: 'table.unique.drop', priority: 0 }, 16 | { type: 'table.index.drop', priority: 0 }, 17 | { type: 'table.drop', priority: 0 }, 18 | { type: 'table.create', priority: 0 }, 19 | { type: 'column.create', priority: 0 }, 20 | ]) 21 | }) 22 | 23 | test('sort ops by priority', () => { 24 | const ops: Operation[] = [ 25 | { type: 'table.create', priority: 1 }, 26 | { type: 'table.create', priority: 0 }, 27 | { type: 'table.create', priority: 3 }, 28 | { type: 'table.create', priority: 2 }, 29 | ] 30 | ops.sort(sortOps) 31 | expect(ops).toEqual([ 32 | { type: 'table.create', priority: 0 }, 33 | { type: 'table.create', priority: 1 }, 34 | { type: 'table.create', priority: 2 }, 35 | { type: 'table.create', priority: 3 }, 36 | ]) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "importHelpers": true, 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "sourceMap": true, 10 | "baseUrl": ".", 11 | "lib": [ 12 | "dom", 13 | "esnext", 14 | ], 15 | "noUnusedLocals": true, 16 | "skipLibCheck": true, 17 | // Strict 18 | "noImplicitAny": true, 19 | "noImplicitThis": true, 20 | "alwaysStrict": true, 21 | "strictBindCallApply": true, 22 | "strictFunctionTypes": true, 23 | // May break autocomplete 24 | "strictNullChecks": false, 25 | }, 26 | "exclude": [ 27 | "node_modules" 28 | ], 29 | "include": [ 30 | "src/**/*.ts", 31 | "tests/**/*.ts" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "node_modules/**" 9 | ] 10 | }, 11 | "rules": { 12 | "quotemark": [true, "single"], 13 | "indent": [true, "spaces", 2], 14 | "interface-name": false, 15 | "ordered-imports": false, 16 | "object-literal-sort-keys": false, 17 | "no-consecutive-blank-lines": false, 18 | "semicolon": [true, "never"], 19 | "space-before-function-paren": [true, "always"], 20 | "no-console": false, 21 | "radix": false, 22 | "no-conditional-assignment": false 23 | } 24 | } 25 | --------------------------------------------------------------------------------