├── .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 |
4 |
5 |
6 | # graphql-migrate
7 |
8 | [](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 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ## Sponsors
23 |
24 | ### Gold
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | ### Silver
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
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 |
31 |
32 |
33 |
34 | ### Silver
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | ### Bronze
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
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 |
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 |
--------------------------------------------------------------------------------