├── .babelrc ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── docker-compose.yml ├── package.json ├── preflight.js ├── readme.md ├── sample.env ├── src ├── __tests__ │ ├── prisma.test.ts │ └── schemas │ │ └── test.graphql ├── adapters │ ├── adapter.ts │ ├── hasura.ts │ └── index.ts ├── index.ts ├── interfaces.ts ├── parsers │ ├── index.ts │ ├── parser.ts │ └── prisma.ts ├── run.ts └── util │ └── utils.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules 3 | .env 4 | docs 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "bracketSpacing": false, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "2.0.0", 6 | "configurations": [ 7 | // { 8 | // "name": "-> GQL-schema-to-hasura", 9 | // "type": "node", 10 | // "request": "launch", 11 | // "runtimeExecutable": "nodemon", 12 | // "args": ["src/index.ts"], 13 | // "runtimeArgs": [ 14 | // "--inspect=33232", 15 | // "--nolazy", 16 | // "--watch", 17 | // "*", 18 | // "-r", 19 | // "ts-node/register" 20 | // ], 21 | // // "sourceMaps": true, 22 | // "restart": true, 23 | // "protocol": "inspector" 24 | // }, 25 | { 26 | "type": "node", 27 | "request": "attach", 28 | "name": "attach to GQL", 29 | "port": 32211, 30 | "restart": true, 31 | "protocol": "inspector" 32 | }, 33 | { 34 | "type": "node", 35 | "request": "launch", 36 | "name": "Jest Tests", 37 | "program": "${workspaceRoot}\\node_modules\\jest\\bin\\jest.js", 38 | "args": ["-i", "--watch"], 39 | "restart": true, 40 | "protocol": "inspector", 41 | // "preLaunchTask": "build", 42 | "internalConsoleOptions": "openOnSessionStart" 43 | // "outFiles": ["${workspaceRoot}/dist/**/*"] 44 | // "envFile": "${workspaceRoot}/.env" 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | hasura: 4 | network_mode: bridge 5 | image: hasura/graphql-engine:v1.0.0-alpha36 6 | ports: 7 | - '8080:8080' 8 | links: 9 | - postgres 10 | depends_on: 11 | - postgres 12 | - pgAdmin 13 | restart: always 14 | environment: 15 | HASURA_GRAPHQL_DATABASE_URL: postgres://root:prisma@postgres:5432/gql 16 | HASURA_GRAPHQL_ENABLE_CONSOLE: 'true' # set to "false" to disable console 17 | ## uncomment next line to set an access key 18 | # HASURA_GRAPHQL_ACCESS_KEY: mysecretaccesskey 19 | postgres: 20 | image: postgres:10.5 21 | network_mode: bridge 22 | restart: always 23 | env_file: .env 24 | environment: 25 | POSTGRES_USER: '${POSTGRES_USER}' 26 | POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' 27 | ports: 28 | - '${POSTGRES_PORT_FORWARDED}:${POSTGRES_PORT}' 29 | volumes: 30 | - postgres_data:/var/lib/postgresql/data 31 | pgAdmin: 32 | image: dpage/pgadmin4 33 | network_mode: bridge 34 | restart: always 35 | links: 36 | - postgres 37 | # - plv8 38 | ports: 39 | - '9090:80' 40 | env_file: .env 41 | environment: 42 | PGADMIN_DEFAULT_EMAIL: '${POSTGRES_USER}' 43 | PGADMIN_DEFAULT_PASSWORD: '${POSTGRES_PASSWORD}' 44 | volumes: 45 | postgres_data: 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-to-sql", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "prestart": "node preflight.js", 7 | "start": "nodemon --watch * -r ts-node/register --inspect=32211 src/run.ts", 8 | "test": "jest --watch", 9 | "docs": "typedoc --exclude ./src/__tests__/**/* --out ./docs ./src", 10 | "docs:deploy": "yarn docs && cd ./docs && now --name graphql-to-sql --public" 11 | }, 12 | "dependencies": { 13 | "axios": "^0.18.1", 14 | "debug": "^4.1.1", 15 | "pg": "^7.8.0", 16 | "prisma-datamodel": "^1.25.3", 17 | "sequelize": "^4.42.0" 18 | }, 19 | "devDependencies": { 20 | "@babel/preset-typescript": "^7.1.0", 21 | "@types/axios": "^0.14.0", 22 | "@types/debug": "^0.0.31", 23 | "@types/jest": "^23.3.13", 24 | "@types/sequelize": "^4.27.34", 25 | "dotenv": "^6.2.0", 26 | "jest": "^24.0.0", 27 | "nodemon": "^1.18.9", 28 | "now": "^13.1.2", 29 | "ts-node": "^8.0.2", 30 | "typedoc": "^0.14.2", 31 | "typescript": "^3.2.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /preflight.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | console.log('PREFLIGHT CHECK') 3 | var fs = require('fs') 4 | var path = require('path') 5 | 6 | try { 7 | var dotenv = require('dotenv') 8 | var parsedEnvFile = dotenv.config().parsed 9 | var sampleFilePath = path.resolve(__dirname, 'sample.env') 10 | var sampleFileContent = fs.readFileSync(sampleFilePath, 'utf8') 11 | var parsedSampleFile = dotenv.parse(sampleFileContent) 12 | var errors = [] 13 | 14 | Object.keys(parsedSampleFile).forEach(key => { 15 | if (!parsedEnvFile.hasOwnProperty(key)) errors.push(key) 16 | }) 17 | 18 | if (!!errors) { 19 | console.log('PREFLIGHT CHECK PASSED') 20 | } else { 21 | console.log('PREFLIGHT CHECK ERRORED') 22 | errors.forEach(key => { 23 | console.error('Please add %s to your env', key) 24 | }) 25 | } 26 | } catch (error) { 27 | console.error('There was an error while preflight check') 28 | console.error(error.message) 29 | } 30 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # graphql to sql 2 | 3 | ** NOTICE ** 4 | 5 | This pacakge is NOT MAINTAINED ANYMORE. 6 | 7 | 8 | Turn your [Prisma](https:prisma.io) GraphQL models into SQL tables without [Prisma](https:prisma.io) server. 9 | 10 | Hook up adapter and do more magic. 11 | 12 | Currently only [Hasura](https://hasura.io) is supported, more will come if needed. 13 | 14 | ## Documentation 15 | 16 | To generate local documentation run `yarn docs` in root of this module. 17 | 18 | Online docs are located [here](https://graphql-to-sql.now.sh) 19 | 20 | ## Debug && Development 21 | 22 | For convenience `.vscode` folder is provided for easier debugging and development. 23 | 24 | This will run the `./src/run.ts` which is the way how I test the module. THis should **not** be imported or used directly, it is a dev/test file. 25 | 26 | ```sh 27 | yarn 28 | yarn start 29 | ``` 30 | 31 | Open VSCode and hit F5 to attach to the current running process. Enjoy debugging. :) 32 | 33 | ## Logging 34 | 35 | [Debug](https://npmjs.org/package/debug) npm package is used to do the logging. 36 | 37 | Each of the Adapters and Parsers has it's own logger for convenience in debugging. 38 | To activate ALL logger set the environmental variable to `DEBUG=GQL2SQL*` and be amazed amount of logs you get :) 39 | 40 | You can specify to log only parsers like `DEBUG=GQL2SQL:parser*` or just prisma parser like `DEBUG=GQL2SQL:parser:prisma` 41 | 42 | Adapters are similar, instead of `parser` use `adapter` like this `DEBUG=GQL2SQL:adapter*` 43 | 44 | Some Adapters like `hasura` have multiple loggers defined, adapter related and api related. If you wish to see them all, do following `DEBUG=GQL2SQL:adapter:hasura*` or just api `DEBUG=GQL2SQL:adapter:hasura:api` 45 | 46 | ## Tasks 47 | 48 | - [ ] `DateTime` must be correctly handled. especially `createdAt` and `updatedAt` 49 | - [ ] `ID!` default CID, currently autoincrement 50 | 51 | ## Notes 52 | 53 | - Prisma uses CID for ID! type, implementation here is to use autoincrement INTEGER. 54 | - `UUID` is defaulted to `UUIDV4` and excepcts that DB supports `uuid_generate_v4()` 55 | 56 | ## On Prisma definition models 57 | 58 | `relatedField` is populated when relation is set on both types: 59 | 60 | Example: 61 | 62 | ```graphql 63 | type A { 64 | id: ID! 65 | b: A @relation(name: "ABConnected") 66 | } 67 | type B { 68 | id: ID! 69 | a: A @relation(name: "ABConnected") 70 | } 71 | ``` 72 | 73 | `relationName` is a name of the `@relation` directive, in above case is `ABConnected` 74 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=gql 2 | POSTGRES_USER=root 3 | POSTGRES_PASSWORD=prisma 4 | POSTGRES_HOST=localhost 5 | POSTGRES_PORT=55432 6 | HASURA_API_ENDPOINT="http://localhost:8080" -------------------------------------------------------------------------------- /src/__tests__/prisma.test.ts: -------------------------------------------------------------------------------- 1 | it('should return true', () => { 2 | expect(true).toBe(true) 3 | }) 4 | -------------------------------------------------------------------------------- /src/__tests__/schemas/test.graphql: -------------------------------------------------------------------------------- 1 | type User { 2 | id: ID! @unique 3 | albums: [album!]! 4 | # this shouldn't create a mapping table for hasura since photo already has userId as FK 5 | photos: Photo @relation(name: "PhotoOwner1") 6 | } 7 | type Rendition { 8 | id: UUID! @unique 9 | photo: Photo @relation(name: "PhotoRendition") 10 | } 11 | type Photo { 12 | id: UUID! @unique 13 | owner: User @relation(name: "PhotoOwner") 14 | rendition: Rendition @relation(name: "PhotoRendition") 15 | albums: [album!]! @relation(name: "PhotoInAlbum") 16 | createdAt: DateTime! 17 | } 18 | type album { 19 | id: UUID! @unique 20 | photos: [Photo!]! @relation(name: "PhotoInAlbum") 21 | } 22 | -------------------------------------------------------------------------------- /src/adapters/adapter.ts: -------------------------------------------------------------------------------- 1 | import {ItableDefinition, KeyValue} from '../interfaces' 2 | import {Sequelize} from 'sequelize' 3 | 4 | export default abstract class Adapter { 5 | /** 6 | * Configure the adapter 7 | * @param config Configuration 8 | */ 9 | abstract configure(config: any): void 10 | /** 11 | * Where magic happens 12 | */ 13 | abstract apply(): void 14 | } 15 | -------------------------------------------------------------------------------- /src/adapters/hasura.ts: -------------------------------------------------------------------------------- 1 | import {ItableDefinition, ITableRelation} from '../interfaces' 2 | import Adapter from './adapter' 3 | import axios, {AxiosInstance} from 'axios' 4 | import {Sequelize, ModelsHashInterface} from 'sequelize' 5 | import debug from 'debug' 6 | import {appLog} from '..' 7 | 8 | const {HASURA_API_ENDPOINT} = process.env 9 | 10 | interface IAPIObjectRelationshipParams { 11 | sourceTableName: string 12 | relationName: string 13 | column: string 14 | } 15 | 16 | interface IAPIArrayRelationshipParams extends IAPIObjectRelationshipParams { 17 | mappingTable: string 18 | } 19 | 20 | interface IHasuraConfig { 21 | sqlResult: Sequelize 22 | debug?: boolean 23 | schema: string 24 | apiEndpoint?: string 25 | tables: ItableDefinition[] 26 | } 27 | 28 | interface IHasuraApiRequestStructure { 29 | type: string 30 | args: { 31 | [k: string]: string 32 | } 33 | } 34 | /** 35 | * Hasura adapter is used to postprocess the relations to fit hasura specific 36 | * implementation. Api calls are as well here, prefixed with `api` 37 | */ 38 | export default class HasuraAdapter extends Adapter { 39 | private config: IHasuraConfig 40 | private api: AxiosInstance 41 | private apiUrl: string 42 | private log: debug.IDebugger 43 | private error: debug.IDebugger 44 | private apiLog: debug.IDebugger 45 | 46 | configure(config: IHasuraConfig): void { 47 | this.config = config 48 | this.log = debug('GQL2SQL:adapter:hasura') 49 | this.apiLog = debug('GQL2SQL:adapter:hasura:api') 50 | this.error = debug('GQL2SQL:adapter:hasura:error') 51 | this.api = axios.create({ 52 | baseURL: HASURA_API_ENDPOINT || 'https://localhost:8080', 53 | }) 54 | 55 | this.apiUrl = '/v1/query' 56 | 57 | if (config['debug']) { 58 | this.api.interceptors.request.use(request => { 59 | this.apiLog('Request', request.url, JSON.stringify(request.data)) 60 | return request 61 | }) 62 | 63 | this.api.interceptors.response.use(response => { 64 | this.apiLog('Response:', response.status, JSON.stringify(response.data)) 65 | return response 66 | }) 67 | } 68 | } 69 | 70 | async apply() { 71 | try { 72 | await this.apiClearMetadata() 73 | await this.apiTrackTables(Object.keys(this.config.sqlResult.models)) 74 | await this.createRelationships(this.config.sqlResult.models) 75 | } catch (error) { 76 | throw new Error(error) 77 | } 78 | } 79 | /** 80 | * Loop through set of tables to find a relation that links to yours 81 | * @param tableName string Target table where relation might be 82 | * @param sourceTableName string Soure table where relation is coming from 83 | */ 84 | protected findRelation( 85 | tableName: string, 86 | sourceTableName: string, 87 | ): null | ITableRelation { 88 | const {tables} = this.config 89 | const baseTable = tables.find(table => table.name === tableName) 90 | if (baseTable) { 91 | return baseTable.relations.find( 92 | relation => relation.target === sourceTableName, 93 | ) 94 | } 95 | return null 96 | } 97 | 98 | /** 99 | * Wrapper function that loops through the Sequilize models and calls API 100 | * Additionally tries to find connection where target is a source 101 | * @param models ModelsHashInterface Sequilize models 102 | */ 103 | async createRelationships(models: any | ModelsHashInterface) { 104 | // reason why any is added is because associations are not part of the model getters, it's a field that is not relevant when workign with sequilize models, because getter for the relationship is handeled differently. This is more for orientation rather than code coverage :) 105 | for (const key in models) { 106 | const model = models[key] 107 | const {associations, tableName: sourceTableName} = model 108 | for (const k in associations) { 109 | const association = associations[k] 110 | 111 | const { 112 | associationType, 113 | as: relationName, 114 | target: {tableName: targetTableName}, 115 | identifierField: column, 116 | } = association 117 | 118 | switch (associationType) { 119 | case 'BelongsTo': 120 | // if Photo belongs to a User, then User hasMany Photos 121 | this.log( 122 | 'create object relationship', 123 | sourceTableName, 124 | relationName, 125 | column, 126 | ) 127 | await this.apiCreateObjectRelationship({ 128 | sourceTableName, 129 | relationName, 130 | column, 131 | }) 132 | const columnForOpositeRelation = this.findRelation( 133 | targetTableName, 134 | sourceTableName, 135 | ) 136 | if (columnForOpositeRelation) { 137 | this.log( 138 | 'create object relationship in the other direction', 139 | targetTableName, 140 | column, 141 | columnForOpositeRelation.fieldName, 142 | sourceTableName, 143 | ) 144 | await this.apiCreateArrayRelationship({ 145 | sourceTableName: targetTableName, 146 | column, 147 | relationName: columnForOpositeRelation.fieldName, 148 | mappingTable: sourceTableName, 149 | }) 150 | } 151 | 152 | break 153 | case 'BelongsToMany': 154 | const { 155 | through: { 156 | model: {tableName: mappingTable}, 157 | }, 158 | } = association 159 | this.log( 160 | 'create array relationship', 161 | sourceTableName, 162 | relationName, 163 | column, 164 | mappingTable, 165 | ) 166 | this.apiCreateArrayRelationship({ 167 | sourceTableName, 168 | relationName, 169 | column, 170 | mappingTable, 171 | }) 172 | break 173 | default: 174 | throw new Error(`Unknown associationType ${associationType}`) 175 | } 176 | } 177 | } 178 | } 179 | /** 180 | * @link https://docs.hasura.io/1.0/graphql/manual/api-reference/schema-metadata-api/relationship.html#create-object-relationship 181 | * @param params IAPIObjectRelationshipParams 182 | */ 183 | async apiCreateObjectRelationship(params: IAPIObjectRelationshipParams) { 184 | const {sourceTableName, relationName, column} = params 185 | const request = { 186 | type: 'create_object_relationship', 187 | args: { 188 | table: { 189 | name: sourceTableName, 190 | schema: this.config.schema, 191 | }, 192 | name: relationName, 193 | using: { 194 | foreign_key_constraint_on: column, 195 | }, 196 | }, 197 | } 198 | return await this.apiCallPost(request) 199 | } 200 | 201 | /** 202 | * @link https://docs.hasura.io/1.0/graphql/manual/api-reference/schema-metadata-api/relationship.html#create-array-relationship 203 | * @param params IAPIArrayRelationshipParams 204 | */ 205 | async apiCreateArrayRelationship(params: IAPIArrayRelationshipParams) { 206 | const {sourceTableName, relationName, column, mappingTable} = params 207 | const request = { 208 | type: 'create_array_relationship', 209 | args: { 210 | table: { 211 | name: sourceTableName, 212 | schema: this.config.schema, 213 | }, 214 | name: relationName, 215 | using: { 216 | foreign_key_constraint_on: { 217 | table: { 218 | name: mappingTable, 219 | schema: this.config.schema, 220 | }, 221 | column: column, 222 | }, 223 | }, 224 | }, 225 | } 226 | return await this.apiCallPost(request) 227 | } 228 | /** 229 | * Wrapper for the actual POST call to hasura server 230 | * @api 231 | */ 232 | async apiCallPost(data, options?: any) { 233 | try { 234 | return await this.api.post(this.apiUrl, data, options) 235 | } catch (error) { 236 | this.error(error.message, JSON.stringify(error.response.data)) 237 | } 238 | } 239 | 240 | /** 241 | * Clears all hasura metadata 242 | * @api 243 | */ 244 | async apiClearMetadata() { 245 | return await this.apiCallPost({type: 'clear_metadata', args: {}}) 246 | } 247 | 248 | /** 249 | * Track table 250 | * @link https://docs.hasura.io/1.0/graphql/manual/api-reference/schema-metadata-api/table-view.html#track-table 251 | * @api 252 | */ 253 | async apiTrackTables(tableNames: string[]) { 254 | // bulkify the operation 255 | let bulkArgs: IHasuraApiRequestStructure[] = [] 256 | tableNames.forEach(tableName => { 257 | bulkArgs.push({ 258 | type: 'track_table', 259 | args: { 260 | schema: this.config.schema, 261 | name: tableName, 262 | }, 263 | }) 264 | }) 265 | 266 | return await this.apiCallPost( 267 | {type: 'bulk', args: bulkArgs}, 268 | { 269 | headers: { 270 | 'X-Hasura-Role': 'admin', 271 | }, 272 | }, 273 | ) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | import {IAdaptersTypes} from '../interfaces' 2 | import HasuraAdapter from './hasura' 3 | 4 | export default abstract class Adapters { 5 | public static create(parserType: IAdaptersTypes = IAdaptersTypes.hasura) { 6 | switch (parserType) { 7 | default: 8 | case IAdaptersTypes.hasura: 9 | return new HasuraAdapter() 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug' 2 | export const appLog = debug('GQL2SQL') 3 | 4 | export {default as prisma} from './parsers/prisma' 5 | export {default as utils} from './util/utils' 6 | export {default as adapters} from './adapters' 7 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import {Sequelize, DefineModelAttributes, SyncOptions} from 'sequelize' 2 | 3 | export enum IAdaptersTypes { 4 | hasura = 'hasura', 5 | } 6 | 7 | export enum ParserTypes { 8 | prisma = 'prisma', 9 | } 10 | 11 | export enum DatabaseTypes { 12 | postgres = 'postgres', 13 | } 14 | 15 | export interface ItableDefinition { 16 | name: string 17 | columns: DefineModelAttributes 18 | relations?: ITableRelation[] 19 | } 20 | 21 | export interface ITableRelation { 22 | name: string 23 | fieldName: string 24 | isList: boolean 25 | target: string 26 | source: string 27 | } 28 | 29 | export interface IParserConfig { 30 | schemaString: string 31 | adapter: IAdaptersTypes 32 | debug?: boolean 33 | database: { 34 | type: DatabaseTypes 35 | connection: Sequelize 36 | syncOptions: SyncOptions 37 | } 38 | } 39 | 40 | export interface KeyValue { 41 | [k: string]: string 42 | } 43 | -------------------------------------------------------------------------------- /src/parsers/index.ts: -------------------------------------------------------------------------------- 1 | import PrismaParser from './prisma' 2 | import {ParserTypes} from '../interfaces' 3 | 4 | export default abstract class Parsers { 5 | public static create(parserType: ParserTypes = ParserTypes.prisma) { 6 | switch (parserType) { 7 | default: 8 | case ParserTypes.prisma: 9 | return new PrismaParser() 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/parsers/parser.ts: -------------------------------------------------------------------------------- 1 | import {IParserConfig} from '../interfaces' 2 | 3 | export default abstract class Parser { 4 | /** 5 | * Configure the parser 6 | * @param config IParserConfig 7 | */ 8 | abstract configure(config: IParserConfig): void 9 | /** 10 | * Where magic happens 11 | */ 12 | abstract run(): void 13 | } 14 | -------------------------------------------------------------------------------- /src/parsers/prisma.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IGQLField, 3 | Parser as prismaParser, 4 | DatabaseType, 5 | IGQLType, 6 | } from 'prisma-datamodel' 7 | import Sequelize, {DefineAttributeColumnOptions} from 'sequelize' 8 | import {ITableRelation, ItableDefinition, IParserConfig} from '../interfaces' 9 | import Parser from './parser' 10 | import Adapters from '../adapters' 11 | import debug from 'debug' 12 | import {appLog} from '..' 13 | 14 | export default class PrismaParser extends Parser { 15 | private config: IParserConfig 16 | private tables: ItableDefinition[] = [] 17 | private log: debug.IDebugger 18 | private sqlLog: debug.IDebugger 19 | private sqlResult: any 20 | 21 | /** 22 | * 23 | * @param tableName 24 | */ 25 | private getTable(tableName: string): ItableDefinition { 26 | return this.tables.find(t => t.name === tableName) 27 | } 28 | 29 | /** 30 | * 31 | * @param tableName 32 | * @param data 33 | */ 34 | private setTable(tableName: string, data: {} | ItableDefinition = {}) { 35 | if (!this.getTable(tableName)) { 36 | this.tables.push({ 37 | columns: {}, 38 | name: tableName, 39 | relations: [], 40 | }) 41 | } else { 42 | this.tables.map(t => { 43 | if (t.name === tableName) { 44 | t = {...t, ...data} 45 | } 46 | }) 47 | } 48 | return this 49 | } 50 | 51 | /** 52 | * 53 | * @param tableName 54 | * @param relationName 55 | * @param data 56 | */ 57 | private setRelation( 58 | tableName: string, 59 | relationName: string, 60 | data: ITableRelation, 61 | ) { 62 | let table = this.getTable(tableName) 63 | if (table.relations.length === 0) { 64 | table.relations.push(data) 65 | } else { 66 | table.relations.push(data) 67 | } 68 | 69 | this.setTable(tableName, table) 70 | } 71 | 72 | private setTables(types: IGQLType[]): any { 73 | types.map(t => { 74 | this.setTable(t.name) 75 | }) 76 | } 77 | configure(config: IParserConfig) { 78 | this.config = config 79 | this.log = debug('GQL2SQL:parser:prisma') 80 | this.sqlLog = debug('GQL2SQL:parser:sequilize') 81 | } 82 | 83 | async run() { 84 | try { 85 | const {types} = this.parseSchemaFromString(this.config.schemaString) 86 | 87 | appLog(`We have ${types.length} types to process. `) 88 | 89 | this.setTables(types) 90 | this.resolveColumDefinitions(types) 91 | this.resolveTableRelations(types) 92 | const tablesWithoutBsRelations = this.cleanRelations() 93 | await this.sequilize(tablesWithoutBsRelations) 94 | this.applyAdapter() 95 | } catch (error) { 96 | appLog('ERROR ::: ', error) 97 | } 98 | } 99 | 100 | /** 101 | * Loop through tables and runs Sequilize to generate table definitions and ultimately tables 102 | * 103 | * Calls raw query to create extension `uuid-ossp` needed for generation of UUIDV4 104 | * @param cleanedTables ItableDefinition[] 105 | */ 106 | async sequilize(cleanedTables: ItableDefinition[]) { 107 | return new Promise((resolve, reject) => { 108 | let definedTables = {} 109 | const {connection, syncOptions} = this.config.database 110 | 111 | // prepare definitions 112 | cleanedTables.forEach(table => { 113 | const {columns, name} = table 114 | 115 | if (!definedTables[name]) { 116 | definedTables[name] = connection.define(name, columns, { 117 | freezeTableName: true, 118 | }) 119 | } 120 | }) 121 | 122 | cleanedTables.forEach(table => { 123 | const {name: tableName, relations} = table 124 | if (relations) { 125 | relations.forEach(relation => { 126 | const { 127 | isList, 128 | name: relationName, 129 | target, 130 | source, 131 | fieldName, 132 | } = relation 133 | 134 | if (!definedTables[target]) { 135 | this.log('Relation model %s is not processed yet', target) 136 | return 137 | } 138 | 139 | if (!isList) { 140 | this.sqlLog( 141 | 'Creating relation %s 1->n %s through %s', 142 | tableName, 143 | target, 144 | fieldName, 145 | ) 146 | 147 | // 1:1 relation 148 | definedTables[source].belongsTo(definedTables[target], { 149 | as: fieldName, 150 | }) 151 | } else { 152 | this.sqlLog( 153 | 'Creating relation %s n->m %s through %s', 154 | tableName, 155 | target, 156 | fieldName, 157 | ) 158 | definedTables[source].belongsToMany(definedTables[target], { 159 | through: relationName, 160 | }) 161 | } 162 | }) 163 | } 164 | }) 165 | 166 | // Create extension that will be used to create uuids 167 | connection.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') 168 | 169 | // Promise has issue with this being this, so that is new this :D 170 | let that = this 171 | 172 | connection 173 | .sync(syncOptions) 174 | .then(d => { 175 | that.sqlResult = d 176 | resolve(d) 177 | }) 178 | .catch(error => { 179 | reject(error) 180 | }) 181 | }) 182 | } 183 | 184 | /** 185 | * Apply the adapter 186 | */ 187 | applyAdapter() { 188 | const adapter = Adapters.create(this.config.adapter) 189 | adapter.configure({ 190 | schema: this.config.database.syncOptions.schema, 191 | sqlResult: this.sqlResult, 192 | tables: this.tables, 193 | debug: this.config['debug'], 194 | }) 195 | 196 | adapter.apply() 197 | } 198 | 199 | /** 200 | * Resolves the column definitions from prisma to simpler format we can use 201 | * @param types IGQLType[] 202 | */ 203 | resolveColumDefinitions(types: IGQLType[]) { 204 | // logic inspired from https://github.com/prisma/prisma/blob/master/cli/packages/prisma-datamodel/src/datamodel/parser/parser.ts#L236 205 | 206 | // Find all the colum names and transform them into format for sequalize 207 | // ignore the relations 208 | for (const typeA of types) { 209 | const {name: tableName, fields} = typeA 210 | let table: ItableDefinition = this.getTable(tableName) 211 | 212 | for (const fieldA of fields) { 213 | if (typeof fieldA.type !== 'string') { 214 | continue // Assume relations 215 | } 216 | const column = this.transformField(fieldA) 217 | if (column) { 218 | table.columns[fieldA.name] = column 219 | } 220 | } 221 | this.setTable(tableName, table) 222 | } 223 | } 224 | 225 | /** 226 | * Resolves the type connectionsfrom prisma to simpler format we can use 227 | * @param types 228 | */ 229 | resolveTableRelations(types: IGQLType[]) { 230 | // here is the code that resolves prisma relations, types and fields https://github.com/prisma/prisma/blob/master/cli/packages/prisma-datamodel/src/datamodel/parser/parser.ts#L251 231 | 232 | // Connect all relations 233 | for (const firstLevelType of types) { 234 | const {name: tableName} = firstLevelType 235 | 236 | let t = firstLevelType 237 | 238 | let table: ItableDefinition = this.getTable(tableName) 239 | 240 | for (const firstLevelField of firstLevelType.fields) { 241 | const { 242 | name: columnName, 243 | relationName, 244 | relatedField, 245 | isList, 246 | type, 247 | } = firstLevelField 248 | 249 | if (typeof type === 'string') { 250 | continue // Assume scalar 251 | } 252 | 253 | const {name: targetTable} = type 254 | 255 | if (relationName && !relatedField) { 256 | /** 257 | * if relatedField is empty in prisma schema means that we do not have 258 | * the relationName declared in the table that we are connecting with 259 | */ 260 | this.log('%s to %s through %s', tableName, targetTable, columnName) 261 | this.setRelation(tableName, columnName, { 262 | isList: isList, 263 | fieldName: columnName, 264 | name: relationName, 265 | target: targetTable, 266 | source: tableName, 267 | }) 268 | } else if (relationName && relatedField) { 269 | /** 270 | * if relatedField is empty in prisma schema means that we do not have 271 | * the relationName declared in the table that we are connecting with 272 | * this means we have many-to-many relation 273 | */ 274 | this.log( 275 | '%s to %s through %s via %s', 276 | tableName, 277 | targetTable, 278 | columnName, 279 | relationName, 280 | ) 281 | this.setRelation(tableName, columnName, { 282 | isList: isList, 283 | fieldName: columnName, 284 | name: relationName, 285 | target: targetTable, 286 | source: tableName, 287 | }) 288 | } else { 289 | this.log( 290 | 'Skipping .... %s type does not have a relation', 291 | targetTable, 292 | ) 293 | } 294 | } 295 | 296 | this.setTable(tableName, table) 297 | } 298 | } 299 | 300 | parseSchemaFromString(schemaString: string) { 301 | const parser = prismaParser.create(DatabaseType[this.config.database.type]) 302 | return parser.parseFromSchemaString(schemaString) 303 | } 304 | 305 | getSqlTypeFromPrisma(type) { 306 | let t = null 307 | if (typeof type !== 'string') { 308 | return t 309 | } 310 | 311 | switch (type) { 312 | case 'ID': 313 | t = Sequelize.INTEGER 314 | this.log('IDs are for now INTEGERS') 315 | break 316 | case 'DateTime': 317 | t = Sequelize.DATE 318 | break 319 | case 'Int': 320 | t = Sequelize.INTEGER 321 | break 322 | default: 323 | t = Sequelize[type.toUpperCase()] 324 | break 325 | } 326 | return t 327 | } 328 | 329 | getDefaultValueFromPrisma(defaultValue, type) { 330 | let t = null 331 | if (typeof type !== 'string') { 332 | return t 333 | } 334 | 335 | switch (type) { 336 | case 'DateTime': 337 | t = Sequelize.literal('CURRENT_TIMESTAMP') 338 | break 339 | case 'UUID': 340 | t = Sequelize.literal('uuid_generate_v4()') 341 | break 342 | default: 343 | t = defaultValue 344 | break 345 | } 346 | return t 347 | } 348 | 349 | transformField(field: IGQLField) { 350 | const {isId, type, defaultValue, isUnique} = field 351 | 352 | let ret: DefineAttributeColumnOptions = { 353 | primaryKey: isId, 354 | defaultValue: defaultValue 355 | ? Sequelize.literal(defaultValue) 356 | : this.getDefaultValueFromPrisma(defaultValue, type), 357 | type: this.getSqlTypeFromPrisma(type), 358 | unique: isUnique, 359 | } 360 | if (type === 'ID') { 361 | ret.autoIncrement = true 362 | delete ret.defaultValue 363 | } 364 | 365 | return ret 366 | } 367 | protected cleanRelations(): ItableDefinition[] { 368 | const cleanedTables = [] 369 | this.tables.map(sourceTable => { 370 | let {relations: relationsA, ...rest} = sourceTable 371 | let relations = [] 372 | relationsA.forEach(firstLevelRelation => { 373 | const {isList, source, target} = firstLevelRelation 374 | 375 | if (!isList) { 376 | // find relation table based on target field 377 | const relatedTable = this.tables.find(t => t.name === target) 378 | if (relatedTable) { 379 | const relatedRelation = relatedTable.relations.find( 380 | t => t.target === source && t.source === target, 381 | ) 382 | 383 | if (relatedRelation) { 384 | const relationAlreadyProcessed = cleanedTables.find(t => { 385 | if (t.name === target) { 386 | return t.relations.find(r => r.name === relatedRelation.name) 387 | } 388 | }) 389 | if (!relationAlreadyProcessed) { 390 | relations.push(firstLevelRelation) 391 | } 392 | } else { 393 | relations.push(firstLevelRelation) 394 | } 395 | } 396 | } else { 397 | relations.push(firstLevelRelation) 398 | } 399 | }) 400 | cleanedTables.push({ 401 | ...rest, 402 | relations, 403 | }) 404 | }) 405 | return cleanedTables 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/run.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * this is helper for cli running, dev purposes only 3 | */ 4 | 5 | require('dotenv').config() 6 | 7 | import {resolve} from 'path' 8 | import Parsers from './parsers' 9 | import {DatabaseTypes, IAdaptersTypes} from './interfaces' 10 | import {readFileSync} from 'fs' 11 | import sequelize from 'sequelize' 12 | 13 | const parser = Parsers.create() 14 | 15 | const { 16 | POSTGRES_DB, 17 | POSTGRES_USER, 18 | POSTGRES_PASSWORD, 19 | POSTGRES_HOST, 20 | POSTGRES_PORT, 21 | POSTGRES_PORT_FORWARDED, 22 | POSTGRES_LOGGING, 23 | } = process.env 24 | 25 | if ( 26 | !POSTGRES_DB || 27 | !POSTGRES_USER || 28 | !POSTGRES_PASSWORD || 29 | !POSTGRES_HOST || 30 | !POSTGRES_PORT 31 | ) { 32 | throw new Error('Pleae set up the env variables') 33 | } 34 | console.log( 35 | POSTGRES_DB, 36 | POSTGRES_USER, 37 | POSTGRES_PASSWORD, 38 | POSTGRES_HOST, 39 | POSTGRES_PORT, 40 | POSTGRES_PORT_FORWARDED, 41 | POSTGRES_LOGGING, 42 | ) 43 | 44 | const seq = new sequelize(POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, { 45 | host: POSTGRES_HOST, 46 | port: parseInt(POSTGRES_PORT_FORWARDED || POSTGRES_PORT, 10), 47 | logging: !!POSTGRES_LOGGING, 48 | dialect: 'postgres', 49 | }) 50 | 51 | parser.configure({ 52 | schemaString: readFileSync( 53 | resolve(__dirname, './__tests__/schemas/test.graphql'), 54 | ).toString(), 55 | adapter: IAdaptersTypes.hasura, 56 | debug: true, 57 | database: { 58 | type: DatabaseTypes.postgres, 59 | connection: seq, 60 | syncOptions: { 61 | force: true, 62 | schema: 'public', 63 | }, 64 | }, 65 | }) 66 | 67 | parser.run().catch(err => { 68 | console.error(err) 69 | }) 70 | -------------------------------------------------------------------------------- /src/util/utils.ts: -------------------------------------------------------------------------------- 1 | import {IGQLField} from 'prisma-datamodel' 2 | 3 | export const generateTableName = (field: string | IGQLField): string => { 4 | if (typeof field === 'string') { 5 | return field.toLowerCase() 6 | } 7 | const {type, name} = field 8 | if (typeof type !== 'string') { 9 | return type.name.toLowerCase() 10 | } else { 11 | return name.toLowerCase() 12 | } 13 | } 14 | export const removeIndexFromArray = (idx: number, array: any[]): number[] => { 15 | var i = array.indexOf(idx) 16 | if (i > -1) { 17 | array.splice(i, 1) 18 | } 19 | return array 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDecoratorMetadata": true, 4 | "experimentalDecorators": true, 5 | "sourceMap": true, 6 | "lib": ["es5", "es6", "dom", "es2015", "esnext.asynciterable"], 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "target": "esnext" 11 | }, 12 | "rootDir": "src", 13 | "outDir": ".build", 14 | "exclude": ["node_modules", "!node_modules/@types"], 15 | "include": ["src/**/*", "typings/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": ["tslint:recommended", "tslint-config-prettier"], 4 | "rules": { 5 | "semicolon": false, 6 | "quotemark": false, 7 | "object-literal-sort-keys": false, 8 | "ordered-imports": false, 9 | "no-console": false, 10 | "prefer-const": false, 11 | "interface-name": false, 12 | "await-promise": false, 13 | "no-floating-promises": false, 14 | "no-implicit-dependencies": false, 15 | "no-unnecessary-qualifier": false, 16 | "no-unnecessary-type-assertion": false, 17 | "no-unused-variable": false, 18 | "no-namespace": false, 19 | "no-use-before-declare": false, 20 | "return-undefined": false, 21 | "strict-type-predicates": false, 22 | "no-string-throw": false 23 | }, 24 | "rulesDirectory": [], 25 | "linterOptions": { 26 | "exclude": [".build", "src/generated", "src/tests"] 27 | } 28 | } 29 | --------------------------------------------------------------------------------