├── .github └── workflows │ ├── mysql.yml │ ├── postgresql.yml │ ├── publish.yml │ ├── sqlite.yml │ └── versioncheck.yml ├── .gitignore ├── .nvmrc ├── README.md ├── bin └── client.js ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts └── semvercheck.js ├── src ├── cmd.ts ├── commands │ ├── init.ts │ ├── makeMigration.ts │ ├── migrate.ts │ ├── prepare.ts │ ├── push.ts │ └── status.ts ├── config.ts ├── constants.ts ├── databaseConnectionPool.ts ├── dbcommands.ts ├── engineProvider.ts ├── engines │ ├── mysql.ts │ ├── postgresql.ts │ └── sqlite.ts ├── index.ts ├── migrationFileUtils.ts ├── processArguments.ts ├── templates │ ├── migration.template.js │ └── raysconfig.template.js ├── types.ts ├── utils.ts └── verifyConfig.ts ├── test ├── engines │ ├── mysql.spec.ts │ ├── postgresql.spec.ts │ └── sqlite.spec.ts ├── init.spec.ts ├── make-migration.spec.ts ├── migrate.spec.ts ├── prepare.spec.ts ├── push.spec.ts ├── test-project │ ├── database.template.db │ ├── generate.js │ ├── package-lock.json │ ├── package.json │ ├── script.ts │ └── tsconfig.json └── testkit │ └── testkit.ts └── tsconfig.json /.github/workflows/mysql.yml: -------------------------------------------------------------------------------- 1 | name: MySQL integration tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | types: [ opened, reopened, synchronize ] 9 | 10 | jobs: 11 | test_mysql: 12 | runs-on: ubuntu-latest 13 | 14 | container: node:14.20-bullseye 15 | 16 | services: 17 | # Label used to access the service container 18 | mysql: 19 | # Docker Hub image 20 | image: mysql:5.7 21 | # Provide the password for postgres 22 | env: 23 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 24 | ports: 25 | - 3306:3306 26 | # needed because the mysql container does not provide a healthcheck 27 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=10s --health-retries=10 28 | 29 | steps: 30 | # Setup MySQL test environment 31 | - name: Setup MySQL cli tool 32 | run: apt-get update && apt-get install --yes default-mysql-client 33 | - run: mysql -u root -h mysql -e 'CREATE DATABASE testdb;' 34 | - run: mysql -u root -h mysql -e 'CREATE DATABASE prisma_shadow;' 35 | - run: mysql -u root -h mysql -e 'CREATE DATABASE rays_shadow;' 36 | # Build project 37 | - name: Check out code from repository 38 | uses: actions/checkout@v3 39 | - run: npm ci 40 | - run: npm run build 41 | - run: npm run pretest 42 | 43 | - name: Testing against MySQL db 44 | run: npm run test:ci 45 | env: 46 | TEST_PROVIDER: mysql 47 | TEST_DATABASE_URL: mysql://root@mysql:3306/testdb 48 | TEST_SHADOW_DATABASE_URL: mysql://root@mysql:3306/prisma_shadow 49 | TEST_SHADOW_DATABASE_NAME: rays_shadow 50 | -------------------------------------------------------------------------------- /.github/workflows/postgresql.yml: -------------------------------------------------------------------------------- 1 | name: Postgresql integration tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | types: [ opened, reopened, synchronize ] 9 | 10 | jobs: 11 | test_postgresql: 12 | runs-on: ubuntu-latest 13 | 14 | container: node:14.20-bullseye 15 | 16 | services: 17 | # Label used to access the service container 18 | postgres: 19 | # Docker Hub image 20 | image: postgres 21 | # Provide the password for postgres 22 | env: 23 | POSTGRES_PASSWORD: postgres 24 | # Set health checks to wait until postgres has started 25 | options: >- 26 | --health-cmd pg_isready 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | 31 | steps: 32 | # Setup postgresql test environment 33 | - name: Setup postgresql cli tool 34 | run: apt-get update && apt-get install --yes postgresql-client 35 | - run: psql -h postgres -U postgres -c 'create database testdb;' 36 | env: 37 | PGPASSWORD: postgres 38 | - run: psql -h postgres -U postgres -c 'create database prisma_shadow;' 39 | env: 40 | PGPASSWORD: postgres 41 | - run: psql -h postgres -U postgres -c 'create database rays_shadow;' 42 | env: 43 | PGPASSWORD: postgres 44 | # Build project 45 | - name: Check out code from repository 46 | uses: actions/checkout@v3 47 | - run: npm ci 48 | - run: npm run build 49 | - run: npm run pretest 50 | 51 | - name: Testing against postgresql db 52 | run: npm run test:ci 53 | env: 54 | TEST_PROVIDER: postgresql 55 | TEST_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/testdb 56 | TEST_SHADOW_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/prisma_shadow 57 | TEST_SHADOW_DATABASE_NAME: rays_shadow 58 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | container: node:14.20-bullseye 12 | 13 | steps: 14 | # Build project 15 | - name: Check out code from repository 16 | uses: actions/checkout@v3 17 | - run: npm ci 18 | - run: npm run build 19 | - uses: JS-DevTools/npm-publish@v1 20 | with: 21 | token: ${{ secrets.NPM_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/sqlite.yml: -------------------------------------------------------------------------------- 1 | name: SQLite integration tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | types: [ opened, reopened, synchronize ] 9 | 10 | jobs: 11 | test_sqlite: 12 | runs-on: ubuntu-latest 13 | 14 | container: node:14.20-bullseye 15 | 16 | steps: 17 | # Build project 18 | - name: Check out code from repository 19 | uses: actions/checkout@v3 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm run pretest 23 | 24 | - name: Testing against SQLite db 25 | run: npm run test:ci 26 | env: 27 | VERBOSE_LOGGING: true 28 | TEST_PROVIDER: sqlite 29 | TEST_DATABASE_URL: file:../testdb.db 30 | -------------------------------------------------------------------------------- /.github/workflows/versioncheck.yml: -------------------------------------------------------------------------------- 1 | name: Version check 2 | 3 | on: 4 | pull_request: 5 | branches: [ "master" ] 6 | types: [ opened, reopened, synchronize ] 7 | 8 | jobs: 9 | version_check: 10 | runs-on: ubuntu-latest 11 | 12 | container: node:14.20-alpine 13 | 14 | steps: 15 | # Build project 16 | - name: Check out code from repository 17 | uses: actions/checkout@v3 18 | - name: Verifying version availability 19 | run: npm run check-version 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | node_modules 4 | build 5 | test/test-project/prisma 6 | test/test-project/.env 7 | test/test-project/raysconfig.js 8 | *.db 9 | *.db-journal 10 | !test/test-project/database.template.db 11 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.17.0 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![MySQL integration tests](https://github.com/lirancr/prisma-rays/actions/workflows/mysql.yml/badge.svg)](https://github.com/lirancr/prisma-rays/actions/workflows/mysql.yml) 2 | [![Postgresql integration tests](https://github.com/lirancr/prisma-rays/actions/workflows/postgresql.yml/badge.svg)](https://github.com/lirancr/prisma-rays/actions/workflows/postgresql.yml) 3 | [![SQLite integration tests](https://github.com/lirancr/prisma-rays/actions/workflows/sqlite.yml/badge.svg)](https://github.com/lirancr/prisma-rays/actions/workflows/sqlite.yml) 4 | [![NPM Version](https://badge.fury.io/js/prisma-rays.svg?style=flat)](https://www.npmjs.com/package/prisma-rays) 5 | 6 | # Prisma Rays 💫 7 | ### Prisma ORM migration tool for developers who want control. 8 | 9 | prisma rays is a schema migration management tool built for [prisma ORM](https://www.prisma.io/). 10 | It is meant to be used as a drop in replacement to the builtin `prisma migrate` cli. 11 | Providing many management improvements and a more intuitive api. 12 | 13 | - Documentation 14 | - [Why to use Prisma Rays](#why-to-use-prisma-rays) 15 | - [Getting started](#getting-started) 16 | - [Prisma rays workflow](#prisma-rays-workflow) 17 | - [Configuration](#configuration) 18 | - [Usage](#usage) 19 | - [How it works](#how-it-works) 20 | - [migration.js](#migrationjs) 21 | - [Prisma Rays vs Prisma Migrate](#prisma-rays-vs-prisma-migrate) 22 | - [Known limits and missing features](#known-limits-and-missing-features) 23 | - [Going back to prisma migrate](#going-back-to-prisma-migrate) 24 | - [Troubleshooting](#troubleshooting) 25 | - [Upgrade from 1.x](#upgrade-from-1x) 26 | 27 | ## Why to use Prisma Rays 28 | 29 | Fair question, the wonderful devs on [prisma migrate](https://www.prisma.io/migrate) have made a great job on the builtin migration tool. 30 | In fact, Prisma Rays uses prisma migrate under the hood to generate its own migrations, so you don't have to worry about differences between schema parsers. 31 | 32 | However, prisma migrate is littered with all kind of counterintuitive behaviours and lack support for some flows which can be useful. 33 | 34 | See a feel list of differences between the two in the [Prisma Rays vs Prisma Migrate](#prisma-rays-vs-prisma-migrate) section 35 | 36 | Prisma Rays is heavily inspired by the UX given by the [Django](https://www.djangoproject.com/) framework builtin migration tool. 37 | 38 | ## Getting started 39 | 40 | #### prerequisites 41 | - [prisma cli](https://www.npmjs.com/package/prisma) installed on your project 42 | - [prisma client](https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases/install-prisma-client-typescript-postgres) installed on your project 43 | - existing `postgres`/`mysql`/`sqlite` database (other [relational databases](https://www.prisma.io/docs/reference/database-reference/supported-databases) might also be supported but were not tested against at the moment) 44 | - `prisma.schema` file with database connection url provided from `.env` file 45 | - if using the auto-generated shadow database, your user credentials to the database should have the [appropriate permissions](https://www.prisma.io/docs/concepts/components/prisma-migrate/shadow-database#shadow-database-user-permissions) for shadow database creation 46 | 47 | #### Installation 48 | 49 | 1. Install package `npm i prisma-rays` 50 | 51 | You may also install as global package instead of using `npx` 52 | 2. in your project's root directory run `npx rays init` 53 | 3. Open the generated `raysconfig.js` file and update it according to your project's setup (see [Configuration](#configuration) section for details). 54 | 55 | if your project's database is brand new (i.e has no tables), make sure your prisma schema contain at least one model and run `npx rays push` 56 | 57 | If your project does not have existing migrations created from `prisma migrate` you can opt in to `prisma rays` 58 | by running `npx rays prepare`. Otherwise, see [Adding to existing projects](#adding-to-existing-projects) 59 | 60 | #### Adding to existing projects 61 | 62 | 1. make sure your database is currently at the starting state that fits your project. 63 | 2. remove all folders in your migrations directory, only keep the `migration_lock.toml` file. 64 | 3. run `npx rays prepare` 65 | 66 | ## Prisma rays workflow 67 | 68 | With prisma rays your typical workflow will look like this: 69 | 1. Modify your prisma schema file 70 | 2. generate migrations based on changes using `makemigrations` command 71 | 3. repeat steps 1 + 2 until you're ready to apply them. 72 | 4. When you wish to apply the generated migrations run the `migrate` command 73 | 5. push migration files to version control 74 | 75 | In production, your workflow should typically be to simply apply your migrations after you've pulled the changes from version control. 76 | 77 | ## Configuration 78 | 79 | Prisma Rays has a single configuration file `raysconfig.js` 80 | 81 | #### Configuration options 82 | 83 | Option | Values | description 84 | --- | --- | --- 85 | migrationsDir | string | A path to your prisma migrations directory 86 | schemaPath | string | A path to your prisma schema file 87 | databaseUrl | string | A connection url to your database (this is the same as value set in your `.env` file) 88 | shadowDatabaseName | string / null | The name of your shadow database if you wish to use predefined one instead of auto-create on each make-migration process. Must be accessible using the same credentials and schema as your database 89 | verboseLogging | boolean | Whether to enable verbose logging by default (instead of requiring `--log` flag) 90 | 91 | #### Basic configuration 92 | 93 | For most setups you only need to set your `migrationsDir` and `schemaPath` and `databaseUrl`. 94 | 95 | #### Configuring to work with cloud hosted / fixed shadow database 96 | 97 | Prisma Rays (and the underlying Prisma Migrate) uses shadow database to generate migration files based on schema changes without affecting your database. 98 | Using Prisma Rays require 2 separate shadow databases - one for prisma rays and another for prisma migrate. 99 | With the basic configuration those databases are automatically created and dropped when creating migrations. 100 | 101 | There are however, cases where you might what to override this behaviour and specify your own shadow databases: 102 | - You don't have the appropriate permissions to create and drop databases. 103 | - Your database is hosted on a cloud service (which does not normally support creating and dropping database instances) 104 | - You use Prisma Rays migration generating in your CI system (for example Prisma Rays tests run on CI) 105 | 106 | It's important to note that shadow databases only play a role when creating migrations (as part of `prepare` or `makemigration`). 107 | if you only need apply/revert migrations you do not need this special setup. 108 | 109 | When overriding the the shadow database behavior, instead of creating and dropping the shadow database, both Prisma Rays and Prisma Migrate 110 | simply drop all the tables in them and reuse them. 111 | 112 | *Configuration* 113 | 114 | 1. In your `raysconfig.js` file update the `shadowDatabaseName` property to match the name of your shadow database to be used by prisma rays. 115 | 116 | This database must be accessible using the same credentials as your database. For example: 117 | ``` 118 | databaseUrl='postgresql://user:password@dbhost:5432/mydb?schema=public'` 119 | shadowDatabaseName='mydb_rays_shadow'` 120 | ``` 121 | 122 | Shadow database url will be: `postgresql://user:password@dbhost:5432/mydb_rays_shadow?schema=public` 123 | 124 | 125 | 2. configure shadow database for Prisma Migrate by setting `shadowDatabaseUrl` in your schema. read more on [prisma migrate docs](https://www.prisma.io/docs/concepts/components/prisma-migrate/shadow-database#cloud-hosted-shadow-databases-must-be-created-manually) 126 | 127 | ``` 128 | datasource db { 129 | provider = "postgresql" 130 | url = "postgresql://user:password@dbhost:5432/mydb?schema=public" 131 | shadowDatabaseUrl = "postgresql://user:password@dbhost:5432/mydb_prisma_shadow?schema=public" 132 | } 133 | ``` 134 | 135 | This database must be different from shadow database set for prisma rays 136 | 137 | 138 | ## Usage 139 | 140 | Optional global cli options with any command: 141 | 142 | Option | Values | description 143 | --- | --- | --- 144 | log | None | Run command with verbose logging. 145 | conf | File path | path to your raysconfig file. 146 | help | None | prints the help chapter on the specific command. 147 | 148 | ### Commands 149 | 150 | #### init 151 | 152 | `npx rays init` 153 | 154 | Setup prisma rays for your project, creating an initial `raysconfig.js` file 155 | 156 | init is only ever required once in the entire lifespan of a project 157 | 158 | #### prepare 159 | 160 | `npx rays prepare ` 161 | 162 | Options: 163 | 164 | Option | Values | Required | description 165 | --- | --- | --- | --- 166 | y | None | No | approve database reset 167 | 168 | Initialize the migration system against the current existing database. 169 | Using this function require to clear the database during the process. 170 | 171 | This function works by looking at the current database and update the prisma schema accordingly. 172 | if you have an existing prisma schema you wish to end up with run `npx prisma db push` before running this command 173 | 174 | Prepare is only ever required once in the entire lifespan of a project 175 | 176 | **example usage:** 177 | 178 | `npx rays prepare --y` 179 | 180 | #### makemigration 181 | 182 | `npx rays makemigration --name ` 183 | 184 | Options: 185 | 186 | Option | Values | Required | description 187 | --- | --- | --- | --- 188 | name | String | yes | suffix to give to the created migration. 189 | blank | None | no | allow the creation of a blank migration if no changes detected in the schema. 190 | autoresolve | None | no | Auto confirm migration generation warnings. 191 | 192 | 193 | Create a migration based on your recent schema changes without applying it to your database. 194 | You can use this function at any time as you like and any database state. 195 | 196 | This function works by: 197 | 1. creating a shadow database and applying all the available migrations to it. 198 | 2. compare the shadow database schema against your current prisma schema and generate the necessary migration 199 | 3. create the revert migration and create a `migration.js` file with both up and down migrations 200 | 4. drop the shadow database 201 | 202 | **example usage:** 203 | 204 | create a migration suffixed by `myFirstMigration`: 205 | 206 | `npx rays makemigration --name myFirstMigration` 207 | 208 | create a migration or blank if no changes, suffixed by `myFirstMigration`: 209 | 210 | `npx rays makemigration --name myFirstMigration --blank` 211 | 212 | 213 | #### migrate 214 | 215 | `npx rays migrate ` 216 | 217 | Options: 218 | 219 | Option | Values | Required | description 220 | --- | --- | --- | --- 221 | name | String | no | Target migration to reach (if not given all up migrations are applied). 222 | fake | None | no | Change the migration state without applying the schema changes to the database. 223 | 224 | 225 | Apply migrations to your database. If migration name option is given, the database will be migrated to this migration 226 | regardless of the direction (i.e up/down) it's found at. Otherwise the topmost migration is used as end target. 227 | 228 | You may use the fake migration option to only mark the migration as applied/reverted without actually effecting the database structure. 229 | This is useful for solving sync issues or error recovery. 230 | 231 | Each migration step is being wrapped in transaction which is either committed or rolled back when the migration step is done. 232 | 233 | This function works by: 234 | 1. finding the migration end target (uses the last migration if non given) 235 | 2. get applied migrations list from database (piggybacking on `prisma migrate`'s migration listing table) 236 | 3. determine required migration direction and steps 237 | 4. if fake migration option not given - load each migration step `migration.js` script and run the up/down functions, wrapped in transaction sql syntax. 238 | 5. update each migration step in `prisma migrate`'s migration listing table (either insert or remove from it) 239 | 6. replaces your prisma schema file with schema file associated with the last successful migration step. 240 | 241 | **example usage:** 242 | 243 | apply all migrations: 244 | 245 | `npx rays migrate` 246 | 247 | mark all un-applied migrations as applied without running them: 248 | 249 | `npx rays migrate --fake` 250 | 251 | apply migrations up/down to `myFirstMigration_20211109182020`: 252 | 253 | `npx rays migrate --name myFirstMigration_20211109182020` 254 | 255 | mark un-applied migrations up/down to `myFirstMigration_20211109182020` as applied/reverted without running them: 256 | 257 | `npx rays migrate --name myFirstMigration_20211109182020 --fake` 258 | 259 | 260 | #### push 261 | 262 | `npx rays push ` 263 | 264 | Options: 265 | 266 | Option | Values | Required | description 267 | --- | --- | --- | --- 268 | y | None | No | approve database reset 269 | 270 | Reset your database to the current state of your schema, this mechanism does not use migrations api and instead 271 | rebuild the database based on the schema. 272 | This command usually required for new projects which never applied any schema to it 273 | 274 | **example usage:** 275 | 276 | `npx rays push --y` 277 | 278 | 279 | #### status 280 | 281 | `npx rays status` 282 | 283 | log the migration and schema status against the database structure 284 | 285 | ## How it works 286 | 287 | Prisma rays does not re-invent the wheel, it uses the same functionality as prisma migrate does but wrap the experience 288 | in a tighter package. 289 | 290 | It uses two dedicated / auto-generated shadow databases to achieve the desired outcome (will be referred to as `rays_shadow` and `prisma_shadow`). 291 | 292 | When creating migrations (either as part of `makemigrations` or `prepare`), prisma rays configure prisma migrate to treat the `rays_shadow` db as the 293 | working database instead of your real working database, this allows prisma rays to avoid making changes / resetting your working database. 294 | 295 | When applying/reverting migrations, prisma rays uses different engines ([pg](https://www.npmjs.com/package/pg), [mysql2](https://www.npmjs.com/package/mysql2), [mssql](https://www.npmjs.com/package/mssql), [sqlite3](https://www.npmjs.com/package/sqlite3)) depending on your database provider 296 | to execute migration queries and then mark the migration as applied/reverted using prisma migrate. 297 | 298 | 299 | ## migration.js 300 | 301 | Prisma Rays work with javascript files to manage migrations. Each migration file (a.k.a step) 302 | exports an array of operation tuples: 303 | 304 | Each tuple contain two functions: 305 | - up (0) - run during forward migration 306 | - down (1) - run during backward migration 307 | 308 | you can add additional operation tuples to perform different actions over your database with one exception: 309 | - do not change the database structure or modify the generated sql calls in the migration script. 310 | why ? because those bits of code must be aligned with the generated `migration.sql` which `prisma migrate` depends on. 311 | 312 | You can of course step in between operations to perform your own logic such as changing the data of your models and so on. 313 | 314 | For data related operations. your up and down operations receive a [client api](#client-api) object which can be used to interact with the 315 | database during the migration process. 316 | 317 | Because of this, `migration.js` files doesn't even need to run any structure changes sql at all. you can use a blank migration 318 | to apply database wide data manipulation. 319 | 320 | in addition to the migration script, prisma rays also create a copy of each migration step schema, so it can be reverted to at any time. 321 | 322 | here are some examples of typical migration scripts (written as helper functions for readability : 323 | 324 | ```javascript 325 | module.exports = [ 326 | [createUsersTable, dropUsersTable], 327 | [createPostsTable, dropPostsTable], 328 | [createConstraint, dropConstraint], 329 | ] 330 | ``` 331 | 332 | ```javascript 333 | module.exports = [ 334 | [setUserNameDefaultValue, replaceDefaultUserNameValueWithNull], 335 | [makeUserNameNonNull, makeUserNameNullable], 336 | ] 337 | ``` 338 | 339 | 340 | #### Client API 341 | 342 | The migration functions are given a `client` object which is connected to the database within the migration transaction. 343 | The client api is as follows: 344 | 345 | ```typescript 346 | interface IDatabaseClientApi { 347 | query: (query: string, params?: any[]) => Promise 348 | execute: (query: string, params?: any[]) => Promise 349 | } 350 | ``` 351 | 352 | use `query` for command to SELECT data from your database, the result is an array of objects matching your query (i.e rows) 353 | 354 | use `execute` for commands that you do not expect to get result back for such as INSERT and UPDATE 355 | 356 | both functions accept a query string and an optional array of arguments to be safely escaped into the resulting query. 357 | 358 | **example usage** 359 | 360 | query data with hard coded values (not recommended): 361 | 362 | `const rows = await query('SELECT * FROM users WHERE firstname = "John" AND lastname = "Doe"')` 363 | 364 | query data with parameters - use the `:?` markup as a parameter insertion point (useful when your parameters derive from unknown source such as user generated content in order to avoid SQL injection): 365 | 366 | `const rows = await query('SELECT * FROM users WHERE firstname = :? AND lastname = :?', ['John', 'Doe'])` 367 | 368 | **WARNING:** Do not build query string in runtime yourself based on uncontrolled data source (such as user provided data), doing so will expose your database do SQL injection 369 | and potential catastrophe 370 | 371 | for example **DO NOT DO THIS:** 372 | ``` 373 | const fname = ... 374 | const lname = ... 375 | const rows = await query('SELECT * FROM users WHERE firstname = "' + fname + '" AND lastname = " + lname + "') 376 | ``` 377 | 378 | ## Prisma Rays vs Prisma Migrate 379 | 380 | #### Creating migrations 381 | 382 | In `prisma migrate`, attempting to create multiple migrations without applying any of them is not supported. if you attempt 383 | to create another migration while you have an un-applied migration pending it will by applied first. 384 | 385 | In `prisma rays`, creating migrations is completely separated from applying the migration, so you can create as many of those as you 386 | want. 387 | 388 | 389 | #### Migration format 390 | 391 | `prisma migrate` only supports an SQL file as your migration. This might impose some limits on what you can do with migrations. 392 | 393 | `prisma rays` on the other hand uses a plain js file as your migration, so you can use it to perform complex data manipulations 394 | and easily resolve data related issues during schema changes. 395 | 396 | For example assume you're adding a new non-null column to an existing table and need to provide a default value, 397 | with `prisma rays` you can just populate a value in your migration file before the sql which create the non-null constraint. 398 | `prisma migrate` overcome this issue (in development) by offering you to reset the database 399 | 400 | Because `prisma rays` uses `prisma migrate` under the hood, you will still see `migration.sql` file created. This file is only kept to support `prisma migrate` usage by `prisma rays` but its not being used by it directly. 401 | 402 | 403 | #### Revert migration 404 | 405 | `prisma migrate` does not support reverting applied migrations. 406 | 407 | `prisma rays` support reverting applied migrations at any depth since it keeps a copy of the prisma schema every time it creates 408 | a migration. 409 | 410 | 411 | #### Applying migrations 412 | 413 | `prisma migrate` is applying all migrations, without using transactions and only in one direction (e.g up), if your migration fail halfway the database is left in undetermined state until manually fixed 414 | 415 | `prisma rays` can apply as many migration steps as you wish in both directions to bring your database to the desired state. Each migration step is being run inside a transaction and is being rolled back on errors 416 | 417 | ## Known limits and missing features 418 | 419 | #### So many logs 420 | 421 | Currently, even with verbose logging option turned off the you will still see every one of `prisma migrate` console logs 422 | when running `prisma rays` commands. Annoying, I know. 423 | 424 | #### Databases support 425 | 426 | At the moment `prisma rays` only supports `postgresql` & `mysql`, (2 out of 3 relational databases prisma migration supports). 427 | This is due to some raw db queries used internally to perform the different functions. 428 | 429 | If you're interested in helping with this issue feel free to submit a pull request, adding your [engine file](./src/engines)` 430 | 431 | ## Going back to prisma migrate 432 | 433 | If you're unhappy with Prisma Rays or simply want to go back to the built in `prisma migrate` tool its easy to do so. 434 | 1. run `npx rays migrate` to bring your database to the latest version 435 | 2. In the migrations folder, for each migration directory delete all the files except for the `migration.sql` 436 | 3. if installed locally, uninstall prisma rays with `npm uninstall prisma-rays` 437 | 438 | ## Troubleshooting 439 | 440 | #### Error: P4001 The introspected database was empty while running rays prepare 441 | 442 | your database is empty so it cannot be used to generate the schema. 443 | Update your prisma schema to an initial point you want to support and run `npx rays push`. 444 | Then run `npx rays prepare` again 445 | 446 | #### Warning: Migration operations for up and down have different amount of operations 447 | 448 | This warning comes up after prisma rays break down the sql generated from prisma migrate into separate sql statements & 449 | the amount of statements in the up migration is different from the down migration. This means that the generated 450 | migration script will have miss-aligned tuple commands in it which makes the migration non-reversible until manually fixed. 451 | 452 | This is more common with sqlite database where some table operations require dropping the whole table and recreate it in multiple statements 453 | rather than apply a single alter table statement 454 | 455 | for example consider the following change: 456 | 457 | up migration - just add a column with default value 458 | ```sql 459 | ALTER TABLE "User" ADD COLUMN "lastname" TEXT DEFAULT 'Doe'; 460 | ``` 461 | 462 | down migration - in sql this change will require recreating the table with the additional column, populate it and replace the original table with the newly created one: 463 | ```sql 464 | CREATE TABLE "new_Users" ...; 465 | INSERT INTO "new_Users" ...; 466 | DROP TABLE "Users"; 467 | ALTER TABLE "new_Users" RENAME TO "Users"; 468 | ``` 469 | 470 | as a result of the difference in operation count prisma rays will generate this migration script 471 | ```javascript 472 | module.exports = [ 473 | [addColumnLastName, createTableNewUsers], 474 | [noop, insertIntoNewUsers], 475 | [noop, dropTableUsers], 476 | [noop, renameNewUsersTableToUsers], 477 | ] 478 | ``` 479 | 480 | As you can see those script operations are not aligned - the down operation is not the reverse of the up operation in the tuple. 481 | This is where manual fixing is required to allow this operation to be reversible. In this case its enough to convert the multistep process into a single step process like so: 482 | 483 | ```javascript 484 | module.exports = [ 485 | [ 486 | addColumnLastName, 487 | async(...args) => { 488 | await createTableNewUsers(...args) 489 | await insertIntoNewUsers(...args) 490 | await dropTableUsers(...args) 491 | await renameNewUsersTableToUsers(...args) 492 | } 493 | ], 494 | ] 495 | ``` 496 | 497 | This kind of fix is called grouping and what `makemigration` does when given the `--autoresolve` flag (or via runtime prompt). Depending on your use case this sort of fix might not be enough/appropriate. 498 | 499 | You should always review created migration script when this sort of warning is showing to ensure reversibility of your migrations 500 | 501 | 502 | ## Upgrade from 1.x 503 | 504 | If you wish to keep your migrations from v1 of prisma rays you can manually convert them to v2 format by following this process: 505 | 506 | Make your `module.exports` be an array, each item in the array should be an array with two items in it. 507 | 508 | take each execute call from your up script, wrap it in a function with similar signature as the up function and put it as the first item 509 | of the inner array in the module exports (create as many arrays as you need to fit all your operations) 510 | 511 | take each execute call from your down script, wrap it in a function with similar signature as the down function and put it as the second item 512 | of the inner array in the module exports - fit the down execute function to the down function so the two items are forward and reverse operations 513 | on the same database entity. 514 | 515 | It might be easier to just show a comparison of the two formats to understand the difference: 516 | 517 | v1.x format 518 | ```javascript 519 | const up = async ({ client }) => { 520 | await client.execute(`+A`) 521 | await client.execute(`+B`) 522 | } 523 | 524 | const down = async ({ client }) => { 525 | await client.execute(`-B`) 526 | await client.execute(`-A`) 527 | } 528 | 529 | module.exports = { up, down } 530 | ``` 531 | 532 | v2.x format 533 | ```javascript 534 | module.exports = [ 535 | [ 536 | // up and down changes to A 537 | async ({ client }) => { await client.execute(`+A`) }, 538 | async ({ client }) => { await client.execute(`-A`) } 539 | ], 540 | [ 541 | // up and down changes to B 542 | async ({ client }) => { await client.execute(`+B`) }, 543 | async ({ client }) => { await client.execute(`-B`) } 544 | ] 545 | ] 546 | ``` 547 | -------------------------------------------------------------------------------- /bin/client.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | const execa = require('execa') 3 | const path = require('path') 4 | 5 | const args = process.argv.slice(2).join(' ') 6 | const indexFilePath = path.normalize(path.join(__dirname, '..', 'build', 'index.js')) 7 | 8 | execa.commandSync(`node ${indexFilePath} ${args}`, { 9 | stdio: 'inherit', 10 | }) 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | bail: true, 6 | testTimeout: 240 * 1000 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prisma-rays", 3 | "version": "4.0.3", 4 | "description": "Alternative migration client for prisma ORM", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "build": "npx -p typescript tsc", 8 | "pretest": "cd ./test/test-project && npm install", 9 | "test": "npx jest --runInBand", 10 | "test:postgresql": "VERBOSE_LOGGING=true TEST_PROVIDER=postgresql TEST_DATABASE_URL=postgresql://postgres:root@localhost:5432/raystest?schema=public TEST_SHADOW_DATABASE_URL=postgresql://postgres:root@localhost:5432/prisma_shadow?schema=public TEST_SHADOW_DATABASE_NAME=raystest_shadow npx jest --runInBand", 11 | "test:mysql": "VERBOSE_LOGGING=true TEST_PROVIDER=mysql TEST_DATABASE_URL=mysql://root:root1234@127.0.0.1:3306/raystest TEST_SHADOW_DATABASE_URL=mysql://root:root1234@127.0.0.1:3306/prisma_shadow TEST_SHADOW_DATABASE_NAME=raystest_shadow npx jest --runInBand", 12 | "test:sqlite": "VERBOSE_LOGGING=true TEST_PROVIDER=sqlite TEST_DATABASE_URL=file:../raystest.db npx jest --runInBand", 13 | "test:ci": "npx jest --runInBand", 14 | "check-version": "node ./scripts/semvercheck.js" 15 | }, 16 | "files": [ 17 | "src", 18 | "build", 19 | "bin", 20 | "README.md" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/lirancr/prisma-rays/" 25 | }, 26 | "bin": { 27 | "rays": "./bin/client.js" 28 | }, 29 | "engines": { 30 | "node": "14.17.x" 31 | }, 32 | "keywords": [ 33 | "prisma", 34 | "rays", 35 | "migrate", 36 | "migration", 37 | "orm", 38 | "database", 39 | "db" 40 | ], 41 | "author": "lirancr", 42 | "license": "ISC", 43 | "dependencies": { 44 | "execa": "^5.1.1", 45 | "minimist": "^1.2.5", 46 | "mssql": "^7.2.1", 47 | "mysql2": "^2.3.0", 48 | "pg": "^8.7.1", 49 | "sqlite3": "^5.0.2" 50 | }, 51 | "devDependencies": { 52 | "@prisma/client": "^4.5.0", 53 | "@types/execa": "^2.0.0", 54 | "@types/jest": "^27.0.2", 55 | "@types/lodash": "^4.14.172", 56 | "@types/minimist": "^1.2.2", 57 | "@types/mssql": "^7.1.3", 58 | "@types/node": "^18.11.7", 59 | "@types/pg": "^8.6.1", 60 | "@types/sqlite3": "^3.1.7", 61 | "jest": "^27.2.2", 62 | "lodash": "^4.17.21", 63 | "prisma": "^4.5.0", 64 | "ts-jest": "^27.0.5", 65 | "typescript": "^4.4.2" 66 | }, 67 | "peerDependencies": { 68 | "@prisma/client": "^4.x", 69 | "prisma": "^4.x" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /scripts/semvercheck.js: -------------------------------------------------------------------------------- 1 | const { get } = require('https') 2 | const { name, version } = require('../package.json') 3 | 4 | const getLatestVersion = () => new Promise((resolve, reject) => { 5 | get(`https://registry.npmjs.org/${name}/latest`, (res) => { 6 | let data = [] 7 | res.on('data', (chunk) => { 8 | data.push(chunk) 9 | }) 10 | res.on('end', () => { 11 | resolve(JSON.parse(data.join('')).version) 12 | }) 13 | }).on('error', (err) => { 14 | console.log('failed to retrieve latest package version from registry', err) 15 | reject(err) 16 | }) 17 | }) 18 | 19 | const parseVersion = (semverString) => { 20 | const [, major, minor, patch] = /(\d+)\.(\d+)\..*?(\d+)/g.exec(semverString) 21 | return [major || 0, minor || 0, patch || 0] 22 | } 23 | 24 | /** 25 | * compare two semver strings 26 | * @param v1str first semver string 27 | * @param v2str second semver string 28 | * @returns {number} 1 if v1str is greater than v2str, -1 if v1str is less than v2str, 0 if they are equal 29 | */ 30 | const compareVersions = (v1str, v2str) => { 31 | console.log('comparing versions', v1str, v2str) 32 | const v1 = parseVersion(v1str) 33 | const v2 = parseVersion(v2str) 34 | for (let i = 0; i < v1.length; i++) { 35 | if (v1[i] > v2[i]) { 36 | return 1 37 | } 38 | if (v1[i] < v2[i]) { 39 | return -1 40 | } 41 | } 42 | return 0 43 | } 44 | 45 | const main = async () => { 46 | console.log('validating package version availability for publishing') 47 | const latestVersion = await getLatestVersion() 48 | if (compareVersions(version, latestVersion) < 0) { 49 | console.error(`package.json version ${version} is less than latest published version ${latestVersion}`) 50 | process.exit(1) 51 | } 52 | console.log('package.json version is available') 53 | } 54 | 55 | main() 56 | -------------------------------------------------------------------------------- /src/cmd.ts: -------------------------------------------------------------------------------- 1 | import * as execa from 'execa' 2 | import { databaseUrl, databaseUrlEnvVarName, schema } from './config' 3 | import { createInterface } from 'readline' 4 | 5 | export const commandSync = (cmd: string): unknown => { 6 | return execa.commandSync(cmd, { 7 | stdio: 'inherit', 8 | }) 9 | } 10 | 11 | export const prismaSync = (cmd: string, env: object = {}): unknown => { 12 | const defaultEnv = { 13 | [databaseUrlEnvVarName]: databaseUrl, 14 | } 15 | 16 | return execa.commandSync(`npx prisma ${cmd} --schema ${schema}`, { 17 | stdio: 'inherit', 18 | env: { 19 | ...defaultEnv, 20 | ...env, 21 | }, 22 | }) 23 | } 24 | 25 | export const ask = (question: string): Promise => { 26 | return new Promise((resolve) => { 27 | const readline = createInterface({ 28 | input: process.stdin, 29 | output: process.stdout 30 | }); 31 | 32 | readline.question(question, (answer: string) => { 33 | readline.close(); 34 | resolve(answer) 35 | }); 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { DEFAULT_CONFIG_FILE_NAME } from '../constants' 3 | import type {InitCommand} from "../types" 4 | import { copyFile } from '../utils' 5 | 6 | const command: InitCommand = async () => { 7 | console.log('Setting up Prisma Rays for your project') 8 | 9 | console.log('Creating raysconfig file') 10 | const configFilePath = path.resolve(DEFAULT_CONFIG_FILE_NAME) 11 | await copyFile( 12 | path.join(__dirname, '..', 'templates', 'raysconfig.template.js'), 13 | configFilePath, 14 | ) 15 | 16 | console.log(`Your project is ready. Review the generated config file at:\n${configFilePath}`) 17 | } 18 | 19 | export default command 20 | module.exports = command 21 | -------------------------------------------------------------------------------- /src/commands/makeMigration.ts: -------------------------------------------------------------------------------- 1 | import {prismaSync, commandSync, ask} from '../cmd' 2 | import { UTF8, SCHEMA_FILE_NAME } from '../constants' 3 | import { schema, databaseUrl, shadowDatabaseName, databaseUrlEnvVarName, databaseEngine, queryBuilder, logger } from '../config' 4 | import * as path from 'path' 5 | import * as fs from 'fs' 6 | import { getMigrationFolders, migrationsPath } from '../migrationFileUtils' 7 | import {dropAllTables, executeRaw, splitMultilineQuery} from '../dbcommands' 8 | import type {MakeMigrationCommand} from "../types" 9 | import { copyFile, writeFile, mkdir, rm, rmdir } from '../utils' 10 | 11 | interface IMigrationScriptParams { 12 | migrationName: string 13 | execUp: string[] 14 | execDown: string[] 15 | autoResolveErrors: boolean 16 | } 17 | 18 | const generateMigrationScript = async ({ migrationName, execUp, execDown, autoResolveErrors}: IMigrationScriptParams): Promise => { 19 | 20 | const createExecuteFunction = (fnBody: string): string => { 21 | return `async ({ client }) => { ${fnBody || ''} }` 22 | } 23 | 24 | const createExecuteCommand = (cmd: string = ''): string => { 25 | const command = cmd.replace(/`/g, '\\`') 26 | if (!command) { 27 | return '' 28 | } 29 | return `await client.execute(\`${command}\`)` 30 | } 31 | 32 | const createExecuteCommands = async (opsUp: string[], opsDown: string[], _autoResolveErrors: boolean): Promise => { 33 | const operationsCount = Math.max(opsUp.length, opsDown.length) 34 | if (opsUp.length > 0 && opsDown.length > 0 && opsUp.length !== opsDown.length) { 35 | logger.warn('Warning: Migration operations for up and down have different amount of operations, please review generated migration script for miss-alignment in up and down operations') 36 | let autoResolveErrors = _autoResolveErrors 37 | if (!_autoResolveErrors) { 38 | autoResolveErrors = await ask('Attempt to auto-resolve this issue? (y/n): ') === 'y' 39 | } 40 | 41 | if (autoResolveErrors) { 42 | logger.warn('Warning: migration operation count miss-match auto-resolved, you should review the generated migration script') 43 | 44 | const joinMigrationOperations = (commands: string[]): string => { 45 | return commands.map(createExecuteCommand).join('\n') 46 | } 47 | 48 | return `[${createExecuteFunction(joinMigrationOperations(opsUp))}, ${createExecuteFunction(joinMigrationOperations(opsDown))}],` 49 | } 50 | } 51 | 52 | const arr = new Array(operationsCount).fill(null) 53 | return arr.map((_, i) => { 54 | const upCommand = createExecuteCommand(opsUp[i]) 55 | const downCommand = createExecuteCommand(opsDown[i]) 56 | return `[${createExecuteFunction(upCommand)}, ${createExecuteFunction(downCommand)}],` 57 | }).join('\n') 58 | } 59 | 60 | const scriptData = fs.readFileSync(path.join(__dirname, '..', 'templates', 'migration.template.js'), UTF8) 61 | .replace('$migrationName', migrationName) 62 | .replace('$operations', await createExecuteCommands(execUp, execDown, autoResolveErrors)) 63 | 64 | const migrationDir = path.join(migrationsPath, migrationName) 65 | if (!fs.existsSync(migrationDir)) { 66 | await mkdir(migrationDir, { recursive: true }) 67 | } 68 | const filepath = path.join(migrationsPath, migrationName, 'migration.js') 69 | await writeFile(filepath, scriptData) 70 | commandSync(`npx prettier --write ${filepath}`) 71 | } 72 | 73 | /** 74 | * Create a migration file without applying it 75 | * 76 | * @param name suffix to append to the file name 77 | * @param blank allow creation of a blank migration if no changes are detected 78 | * @param autoResolveErrors toggle automatic handling of up/down operation count miss-match 79 | * @return {Promise} the full name of the newly created migration 80 | */ 81 | const command: MakeMigrationCommand = async (name: string, blank: boolean, autoResolveErrors: boolean): Promise => { 82 | // prepare sterile environment for migration generating 83 | const isShadowDatabaseConfigured = shadowDatabaseName!! 84 | 85 | const dbName = databaseEngine.getDatabaseName(databaseUrl) 86 | const shadowDbName: string = isShadowDatabaseConfigured 87 | ? shadowDatabaseName! 88 | : `${dbName}_shadow_${name}_${Date.now()}` 89 | const shadowDbUrl = databaseEngine.makeUrlForDatabase(databaseUrl, shadowDbName) 90 | 91 | const shadowEnv = { 92 | [databaseUrlEnvVarName]: shadowDbUrl 93 | } 94 | 95 | if (isShadowDatabaseConfigured) { 96 | await dropAllTables(shadowDbUrl) 97 | } else if (databaseEngine.isDatabaseOnFile) { 98 | const dbFilePath = databaseEngine.getDatabaseFilesPath(databaseUrl, { schemaPath: schema }).db 99 | const shadowDbFilePath = databaseEngine.getDatabaseFilesPath(shadowDbUrl, { schemaPath: schema }).db 100 | await copyFile(dbFilePath, shadowDbFilePath) 101 | await dropAllTables(shadowDbUrl) 102 | } else { 103 | await executeRaw(queryBuilder.dropDatabaseIfExists(shadowDbName), false) 104 | await executeRaw(queryBuilder.createDatabase(shadowDbName), false) 105 | } 106 | 107 | const cleanup = async () => { 108 | await Promise.all( 109 | databaseEngine.getDatabaseFilesPath(shadowDbUrl, { schemaPath: schema }).metafiles 110 | .map((f) => { 111 | logger.log('removing metafile', f) 112 | return rm(f) 113 | })) 114 | 115 | if (isShadowDatabaseConfigured) { 116 | await dropAllTables(shadowDbUrl) 117 | } else if (databaseEngine.isDatabaseOnFile) { 118 | logger.log('removing database file', shadowDbUrl) 119 | await rm(databaseEngine.getDatabaseFilesPath(shadowDbUrl, { schemaPath: schema }).db) 120 | } else { 121 | await executeRaw(queryBuilder.dropDatabaseIfExists(shadowDbName), false) 122 | } 123 | } 124 | 125 | try { 126 | // create migration 127 | const previousMigration = (await getMigrationFolders()).pop() 128 | logger.log('Creating up migration') 129 | prismaSync(`migrate dev --create-only --skip-seed --skip-generate --name ${name}`, shadowEnv) 130 | 131 | const newMigration = (await getMigrationFolders()).pop() 132 | 133 | if (!newMigration) { 134 | logger.log('migration creation aborted') 135 | return null 136 | } 137 | 138 | const migrationFileParams: IMigrationScriptParams = { 139 | migrationName: newMigration, 140 | execUp: [], 141 | execDown: [], 142 | autoResolveErrors, 143 | } 144 | 145 | migrationFileParams.execUp = splitMultilineQuery(fs.readFileSync(path.join(migrationsPath, newMigration, 'migration.sql'), UTF8)) 146 | 147 | // check if new migration contain any changes at all 148 | if (migrationFileParams.execUp.length === 0) { 149 | if (blank) { 150 | logger.log('No schema changes detected. Creating blank migration') 151 | } else { 152 | logger.log('No schema changes detected. Migration not created') 153 | await rm(path.join(migrationsPath, newMigration), { recursive: true }) 154 | return null 155 | } 156 | } 157 | 158 | // copy current schema for future reverts 159 | const currentSchemaBackup = path.join(migrationsPath, newMigration, SCHEMA_FILE_NAME) 160 | await copyFile(schema, currentSchemaBackup) 161 | 162 | // create a revert migration script based on previous schema 163 | if (previousMigration) { 164 | const previousSchema = path.join(migrationsPath, previousMigration, SCHEMA_FILE_NAME) 165 | 166 | await copyFile(previousSchema, schema) 167 | 168 | logger.log('Creating down migration') 169 | prismaSync(`migrate dev --create-only --skip-seed --skip-generate --name revert`, shadowEnv) 170 | 171 | const revertMigration = (await getMigrationFolders()).pop()! 172 | 173 | migrationFileParams.execDown = splitMultilineQuery(fs.readFileSync(path.join(migrationsPath, revertMigration, 'migration.sql'), UTF8)) 174 | 175 | // cleanup 176 | await rmdir(path.join(migrationsPath, revertMigration), { recursive: true }) 177 | await copyFile(currentSchemaBackup, schema) 178 | } 179 | 180 | await generateMigrationScript(migrationFileParams) 181 | 182 | await cleanup() 183 | 184 | return newMigration 185 | } catch (e) { 186 | await cleanup() 187 | throw e 188 | } 189 | } 190 | 191 | export default command 192 | module.exports = command 193 | -------------------------------------------------------------------------------- /src/commands/migrate.ts: -------------------------------------------------------------------------------- 1 | import { getMigrationFolders } from "../migrationFileUtils"; 2 | import {getAppliedMigrations, insertMigration, deleteMigration, executeRawOne} from '../dbcommands' 3 | import { prismaSync } from "../cmd"; 4 | import { SCHEMA_FILE_NAME } from '../constants' 5 | import {schema, migrationsPath, queryBuilder, databaseEngine, logger, databaseUrl} from "../config"; 6 | import * as path from 'path'; 7 | import { copyFile } from '../utils' 8 | import type {IMigrationScript, MigrateCommand, IDatabaseClientApi, IDatabaseConnection} from "../types"; 9 | 10 | /** 11 | * 12 | * @param name? optional file name to migrate up/down to 13 | * @param fake? optional flag to only apply changes to migration state without applying database changes 14 | * @return {Promise} 15 | */ 16 | const command: MigrateCommand = async ({ name, fake } = {}): Promise => { 17 | const allMigrations = await getMigrationFolders() 18 | const currentDbState = await getAppliedMigrations() 19 | 20 | logger.log('verifying migration/database sync state') 21 | if (currentDbState.length > allMigrations.length) { 22 | throw new Error('Unable to migrate database - database is at a later state than there are migrations.'+JSON.stringify(currentDbState)) 23 | } 24 | 25 | currentDbState.forEach((n: string, i: number) => { 26 | if (n !== allMigrations[i]) { 27 | throw new Error(`Unable to migrate database - database has an unexpected intermediate migration which is missing from the migrations folder.\nExpected ${n} but found ${allMigrations[i]} at index ${i}`) 28 | } 29 | }) 30 | 31 | logger.log('migration/database sync state check passed') 32 | 33 | const targetIndex = name ? allMigrations.indexOf(name) : allMigrations.length - 1 34 | 35 | if (name && targetIndex === -1) { 36 | throw new Error(`Unable to migrate database to migration named ${name} - No such migration exists`) 37 | } 38 | 39 | const migrationsToApply = [] 40 | let migrateUp = true 41 | 42 | if (targetIndex + 1 > currentDbState.length) { 43 | // migrate up 44 | migrationsToApply.push(...allMigrations.slice(currentDbState.length, targetIndex + 1)) 45 | logger.log('migrating up', migrationsToApply.length, 'migrations') 46 | } else if (targetIndex + 1 < currentDbState.length) { 47 | // migrate down 48 | migrateUp = false 49 | migrationsToApply.push(...allMigrations.slice(targetIndex + 1, currentDbState.length).reverse()) 50 | logger.log('migrating down', migrationsToApply.length, 'migrations') 51 | } else { 52 | // dont migrate 53 | logger.log('Nothing to migrate, database is up to date.') 54 | return 55 | } 56 | 57 | if (name) { 58 | logger.log('Bringing database', migrateUp ? 'up' : 'down', 'to', name) 59 | } 60 | 61 | for (let migration of migrationsToApply) { 62 | logger.log(fake ? 'Fake -' : '', migrateUp ? 'Applying' : 'Reverting','migration -', migration) 63 | const migrationScript: IMigrationScript = require(path.join(migrationsPath, migration, 'migration.js')) 64 | const operations = migrateUp ? migrationScript : migrationScript.map((upDown) => upDown.reverse()).reverse() 65 | if (!fake) { 66 | const connection: IDatabaseConnection = await databaseEngine.createConnection(databaseUrl, logger, { schemaPath: schema }) 67 | const client: IDatabaseClientApi = { query: connection.query, execute: connection.execute } 68 | 69 | let migrationError = null 70 | let lastSuccessfulOperationIndex = -1 71 | while (lastSuccessfulOperationIndex < operations.length - 1) { 72 | try { 73 | await executeRawOne(queryBuilder.transactionBegin()) 74 | await operations[lastSuccessfulOperationIndex + 1][0]({client}) 75 | await executeRawOne(queryBuilder.transactionCommit()) 76 | lastSuccessfulOperationIndex++ 77 | } catch (e: any) { 78 | await executeRawOne(queryBuilder.transactionRollback()) 79 | logger.error(`Migration failed [index: ${lastSuccessfulOperationIndex + 1}]:`, e.message) 80 | migrationError = e 81 | break 82 | } 83 | } 84 | 85 | if (migrationError) { 86 | logger.log('Rolling back migration operations') 87 | // rollback 88 | while (lastSuccessfulOperationIndex > -1) { 89 | await operations[lastSuccessfulOperationIndex][1]({client}) 90 | lastSuccessfulOperationIndex-- 91 | } 92 | } 93 | 94 | await connection.disconnect() 95 | 96 | if (migrationError) { 97 | throw migrationError 98 | } 99 | } 100 | await (migrateUp ? insertMigration(migration) : deleteMigration(migration)) 101 | 102 | const currentStateSchema = path.join(migrationsPath, 103 | migrateUp 104 | ? migration 105 | : allMigrations[Math.max(allMigrations.indexOf(migration) - 1, 0)], 106 | SCHEMA_FILE_NAME) 107 | 108 | logger.log('updating schema definition according to migration') 109 | await copyFile( 110 | currentStateSchema, 111 | schema 112 | ) 113 | } 114 | 115 | logger.log('Migration successful, updating client type definitions') 116 | prismaSync(`generate`) 117 | } 118 | 119 | export default command 120 | module.exports = command 121 | -------------------------------------------------------------------------------- /src/commands/prepare.ts: -------------------------------------------------------------------------------- 1 | import { prismaSync, ask } from '../cmd' 2 | import makeMigration from './makeMigration' 3 | import migrate from './migrate' 4 | import { getMigrationFolders } from '../migrationFileUtils' 5 | import { clearMigrationsTable } from '../dbcommands' 6 | import type {PrepareCommand} from "../types"; 7 | import { logger } from '../config' 8 | 9 | /** 10 | * 11 | * @return {Promise} 12 | */ 13 | const command: PrepareCommand = async (approveReset): Promise => { 14 | // make sure we dont have existing migrations 15 | if ((await getMigrationFolders()).length > 0) { 16 | throw new Error('Project already initialized (migrations folder is not empty)') 17 | } 18 | 19 | if (!approveReset && await ask('Initializing the database will require dropping all the data from it. Continue? (y/n): ') !== 'y') { 20 | logger.log('aborting') 21 | return 22 | } 23 | 24 | await clearMigrationsTable() 25 | 26 | // sync schema with existing database state 27 | prismaSync(`db pull`) 28 | prismaSync(`migrate reset --force --skip-generate --skip-seed`) 29 | 30 | const initialMigrationFile = await makeMigration('init', false, false) 31 | 32 | await migrate({name: initialMigrationFile!}) 33 | } 34 | 35 | export default command 36 | module.exports = command 37 | -------------------------------------------------------------------------------- /src/commands/push.ts: -------------------------------------------------------------------------------- 1 | import { prismaSync, ask } from '../cmd' 2 | import { logger } from '../config' 3 | import type {PushCommand} from "../types" 4 | 5 | /** 6 | * Reset the database to the current state of your schema (without applying migrations) 7 | * @return {Promise} the full name of the newly created migration 8 | */ 9 | const command: PushCommand = async (approveReset: boolean): Promise => { 10 | if (!approveReset && await ask('Pushing schema to database require database to be reset. Continue? (y/n): ') !== 'y') { 11 | logger.log('aborting') 12 | return 13 | } 14 | 15 | prismaSync(`db push --accept-data-loss --skip-generate --force-reset`) 16 | } 17 | 18 | export default command 19 | module.exports = command 20 | -------------------------------------------------------------------------------- /src/commands/status.ts: -------------------------------------------------------------------------------- 1 | import {prismaSync} from '../cmd' 2 | import type {StatusCommand} from "../types"; 3 | 4 | /** 5 | * Prints the database migration status 6 | * 7 | * @return {Promise} 8 | */ 9 | const command: StatusCommand = async (): Promise => { 10 | prismaSync(`migrate status`) 11 | } 12 | 13 | export default command 14 | module.exports = command 15 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs' 3 | import processArguments from './processArguments' 4 | import * as engineProvider from './engineProvider' 5 | import { DEFAULT_CONFIG_FILE_NAME, UTF8 } from './constants' 6 | import type {ILogger, RaysConfig} from "./types"; 7 | 8 | const processArgs = processArguments() 9 | 10 | const { 11 | migrationsDir, 12 | schemaPath, 13 | databaseUrl, 14 | shadowDatabaseName, 15 | verboseLogging, 16 | } = require(path.resolve(processArgs.conf || DEFAULT_CONFIG_FILE_NAME)) as RaysConfig 17 | 18 | import { getDatabaseUrlEnvVarNameFromSchema } from './utils' 19 | 20 | export const verbose = verboseLogging || 'log' in processArgs 21 | if (verbose) { 22 | console.log('Verbose logging enabled') 23 | } 24 | 25 | export const schema = path.resolve(schemaPath) 26 | 27 | const schemaFile = fs.readFileSync(schema, UTF8) 28 | 29 | export const logger: ILogger = { 30 | log: verbose ? console.log : () => {}, 31 | warn: console.warn, 32 | error: console.error, 33 | info: verbose ? console.info : () => {}, 34 | query: verbose ? (db: string, ...args: any[]) => { console.log (db, ':', ...args)} : () => {}, 35 | } 36 | 37 | export const databaseUrlEnvVarName = getDatabaseUrlEnvVarNameFromSchema(schemaFile)! 38 | export const databaseEngine = engineProvider.engineFor(databaseUrl)! 39 | export const queryBuilder = databaseEngine.queryBuilderFactory(databaseUrl)! 40 | export const migrationsPath = path.resolve(migrationsDir) 41 | export { databaseUrl, shadowDatabaseName } 42 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_CONFIG_FILE_NAME = 'raysconfig.js' 2 | export const SCHEMA_FILE_NAME = 'migration.schema.prisma' 3 | export const UTF8 = 'utf8' 4 | export const PRISMA_MIGRATIONS_TABLE = '_prisma_migrations' 5 | export const PRISMA_MIGRATION_NAME_COL = 'migration_name' 6 | export const ENGINE_PARAM_PLACEHOLDER = ':?' 7 | -------------------------------------------------------------------------------- /src/databaseConnectionPool.ts: -------------------------------------------------------------------------------- 1 | import type {IDatabaseConnection} from "./types"; 2 | 3 | const connectionsPool = new Map() 4 | 5 | export const addConnection = (connection: IDatabaseConnection) => { 6 | connectionsPool.set(connection, connection) 7 | } 8 | 9 | export const removeConnection = (connection: IDatabaseConnection) => { 10 | connectionsPool.delete(connection) 11 | } 12 | 13 | export const releaseConnections = (): Promise => { 14 | const pending: Promise[] = [] 15 | connectionsPool.forEach(connection => pending.push(connection.disconnect())) 16 | return Promise.all(pending) 17 | } 18 | -------------------------------------------------------------------------------- /src/dbcommands.ts: -------------------------------------------------------------------------------- 1 | import {PrismaClient} from "@prisma/client" 2 | import {verbose, databaseUrl, queryBuilder, databaseEngine, logger, schema } from "./config" 3 | import { prismaSync } from './cmd' 4 | import { PRISMA_MIGRATIONS_TABLE, PRISMA_MIGRATION_NAME_COL } from "./constants"; 5 | import {IDatabaseConnection} from "./types"; 6 | 7 | const createPrismaClient = (url:string): PrismaClient => new PrismaClient({ 8 | log: verbose ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'], 9 | datasources: { 10 | db: { 11 | url, 12 | } 13 | } 14 | }) 15 | 16 | export const prisma = createPrismaClient(databaseUrl) 17 | const dbConnection = databaseEngine.createConnection(databaseUrl, logger, { schemaPath: schema }) 18 | 19 | export const executeRawOne = async (command: string, connection: Promise = dbConnection): Promise => { 20 | const client = await connection 21 | return client.execute(command) 22 | } 23 | 24 | const queryRawOne = async (command: string, connection: Promise = dbConnection): Promise => { 25 | const client = await connection 26 | return client.query(command) 27 | } 28 | 29 | /** 30 | * Drops the migrations table if exists 31 | * 32 | * @return {Promise} 33 | */ 34 | export const clearMigrationsTable = async (): Promise => { 35 | try { 36 | await executeRawOne(queryBuilder.deleteAllFrom(PRISMA_MIGRATIONS_TABLE)) 37 | } catch (e) {} 38 | } 39 | 40 | /** 41 | * Insert a new migration record to the migrations table 42 | * @param name full migration folder name to insert 43 | * 44 | * @return {Promise} 45 | */ 46 | export const insertMigration = async (name: string): Promise => { 47 | prismaSync(`migrate resolve --applied ${name}`) 48 | } 49 | 50 | /** 51 | * Delete a migration record from the migrations table 52 | * @param name full migration folder name to delete 53 | * 54 | * @return {Promise} 55 | */ 56 | export const deleteMigration = async (name: string): Promise => { 57 | return executeRawOne(queryBuilder.deleteFromBy(PRISMA_MIGRATIONS_TABLE, PRISMA_MIGRATION_NAME_COL, name)) 58 | } 59 | 60 | /** 61 | * Retrieve the list of applied migrations 62 | * 63 | * @return {Promise>} 64 | */ 65 | export const getAppliedMigrations = async (): Promise => { 66 | try { 67 | const migrations = await queryRawOne(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) 68 | return migrations.map((migration: any) => migration[PRISMA_MIGRATION_NAME_COL]).sort() 69 | } catch (e) { 70 | return [] 71 | } 72 | } 73 | 74 | /** 75 | * convert a single multi-command string into separate commands which can be run using prisma.$executeRaw 76 | * 77 | * @param query string 78 | * @return {Array} 79 | */ 80 | export const splitMultilineQuery = (query: string): string[] => { 81 | return query.split('\n') 82 | // remove comments 83 | .filter((cmd) => !cmd.startsWith('-- ')) 84 | .join('\n') 85 | .split(';') 86 | .map(cmd => cmd.trim()) 87 | .filter((cmd) => cmd) 88 | .map((cmd) => cmd + ';') 89 | } 90 | 91 | export const dropAllTables = async (databaseUrl: string): Promise => { 92 | logger.log('dropping all tables from', databaseUrl) 93 | const connection = databaseEngine.createConnection(databaseUrl, logger, { schemaPath: schema }) 94 | const client = await connection 95 | const preQuery = queryBuilder.setForeignKeyCheckOff() 96 | const postQuery = queryBuilder.setForeignKeyCheckOn() 97 | const query = queryBuilder.selectAllTables(databaseEngine.getDatabaseName(databaseUrl)) 98 | 99 | const tables = await queryRawOne(query, connection) as { tablename: string }[] 100 | if (tables.length > 0) { 101 | const command = tables.map(({tablename}) => queryBuilder.dropTableIfExistsCascade(tablename)).join('\n') 102 | await executeRaw(preQuery + command + postQuery, true, connection) 103 | } 104 | 105 | if ((await queryRawOne(query, connection) as { tablename: string }[]).length > 0) { 106 | throw new Error('failed to remove tables of database '+databaseUrl) 107 | } 108 | 109 | await client.disconnect() 110 | } 111 | 112 | export const executeRaw = async (query: string, transaction = true, connection: Promise = dbConnection): Promise => { 113 | const commands = splitMultilineQuery(query) 114 | 115 | const client = await connection 116 | if (transaction) { 117 | await client.execute(queryBuilder.transactionBegin()) 118 | } 119 | try { 120 | let res 121 | for (const cmd of commands) { 122 | res = await client.execute(cmd) 123 | } 124 | if (transaction) { 125 | await client.execute(queryBuilder.transactionCommit()) 126 | } 127 | return res 128 | } catch (e) { 129 | if (transaction) { 130 | await client.execute(queryBuilder.transactionRollback()) 131 | } 132 | throw e 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/engineProvider.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A database engine in PrismaRays terms is an abstraction layer between prisma rays and the specific database implementations. 3 | * This way PrismaRays function, using execRaw/queryRaw without taking into account which database type it is. 4 | * 5 | * all engines should be placed in ./engines and be named according to their prisma name (i.e the provider set in 6 | * the prisma schema file). 7 | * 8 | * When changing one engine you must change them all! 9 | */ 10 | 11 | import type {IEngine} from './types' 12 | import * as fs from 'fs' 13 | import * as path from 'path' 14 | 15 | const enginesDir: string = path.join(__dirname, 'engines') 16 | 17 | export const ALLOWED_ENGINES = fs.readdirSync(enginesDir) 18 | .map((file: string) => file.split('.')[0]) 19 | 20 | const engines: IEngine[] = ALLOWED_ENGINES.map((engine: string) => require(path.join(enginesDir, engine))) 21 | 22 | export const engineFor = (databaseUrl: string): IEngine => { 23 | const engine = engines.find(engine => engine.isEngineForUrl(databaseUrl)) 24 | if (!engine) { 25 | throw new Error('Unknown engine for url :'+databaseUrl) 26 | } 27 | return engine 28 | } 29 | -------------------------------------------------------------------------------- /src/engines/mysql.ts: -------------------------------------------------------------------------------- 1 | import {QueryBuilderFactory, IEngine, IQueryBuilder, IDatabaseConnection, ILogger} from "../types"; 2 | import * as connectionPool from "../databaseConnectionPool" 3 | import mySQL from 'mysql2/promise' 4 | import {ENGINE_PARAM_PLACEHOLDER} from "../constants"; 5 | 6 | const MYSQL2_PARAM_PLACEHOLDER = '?' 7 | 8 | const isEngineForUrl = (databaseUrl: string): boolean => { 9 | return /^mysql:\/\//i.test(databaseUrl) 10 | } 11 | 12 | const getDatabaseName = (databaseUrl: string): string => { 13 | const dbName = databaseUrl.match(/mysql:\/\/.+(?::.+)?@.+:[0-9]+\/(.+)/i)?.pop() 14 | if (!dbName) { 15 | throw new Error(`EngineError:mysql - databaseUrl did not match expected pattern: ${databaseUrl}`) 16 | } 17 | return dbName 18 | } 19 | 20 | const makeUrlForDatabase = (databaseUrl: string, dbName: string): string => { 21 | const url = databaseUrl.replace(/(mysql:\/\/.+(?::.+)?@.+:[0-9]+\/)(.+)/i, `$1${dbName}`) 22 | if (!url.includes(dbName)) { 23 | throw new Error(`EngineError:mysql - databaseUrl did not match expected pattern: ${databaseUrl}`) 24 | } 25 | return url 26 | } 27 | 28 | const queryBuilderFactory: QueryBuilderFactory = () => { 29 | const queryBuilder: IQueryBuilder = { 30 | deleteAllFrom: (table) => `DELETE FROM ${table};`, 31 | deleteFromBy: (table, column, value) => `DELETE FROM ${table} where ${column}='${value}';`, 32 | selectAllFrom: (table) => `SELECT * FROM ${table};`, 33 | insertInto: (table, values) => { 34 | const entries = Object.entries(values) 35 | return `INSERT INTO ${table} (${entries.map(e => e[0]).join(',')}) VALUES ('${entries.map(e => e[1]).join("','")}');` 36 | }, 37 | updateAll: (table, values) => { 38 | const entries = Object.entries(values) 39 | return `UPDATE ${table} SET ${entries.map(([k, v]) => k+"='"+v+"'").join(',')};` 40 | }, 41 | dropDatabaseIfExists: (db) => `DROP DATABASE IF EXISTS ${db};`, 42 | createDatabase: (db) => `CREATE DATABASE ${db};`, 43 | transactionBegin: () => `START TRANSACTION;`, 44 | transactionCommit: () => `COMMIT;`, 45 | transactionRollback: () => `ROLLBACK;`, 46 | setForeignKeyCheckOn: () => `SET FOREIGN_KEY_CHECKS = 1;`, 47 | setForeignKeyCheckOff: () => `SET FOREIGN_KEY_CHECKS = 0;`, 48 | dropTableIfExistsCascade: (table) => `DROP TABLE IF EXISTS ${table};`, 49 | selectAllTables: (db) => `SELECT table_name AS tablename FROM information_schema.tables WHERE table_schema = '${db}';`, 50 | } 51 | 52 | return queryBuilder 53 | } 54 | 55 | /** 56 | * convert prisma rays param placeholder characters to the engine's one 57 | */ 58 | const normalizeQuery = (query: string): string => { 59 | return query.replace(ENGINE_PARAM_PLACEHOLDER, MYSQL2_PARAM_PLACEHOLDER) 60 | } 61 | 62 | const createConnection = async (databaseUrl: string, logger: ILogger): Promise => { 63 | const dbname = getDatabaseName(databaseUrl) 64 | const client = await mySQL.createConnection(databaseUrl) 65 | 66 | const connection: IDatabaseConnection = { 67 | query: async (q, params) => { 68 | logger.query(dbname, q, params) 69 | const [rows] = params ? await client.query(normalizeQuery(q), params) : await client.query(q) 70 | return rows as unknown[] 71 | }, 72 | execute: async (q, params) => { 73 | logger.query(dbname, q, params) 74 | params ? await client.query(normalizeQuery(q), params) : await client.query(q) 75 | }, 76 | disconnect: async () => { 77 | connectionPool.removeConnection(connection) 78 | await client.end() 79 | } 80 | } 81 | 82 | connectionPool.addConnection(connection) 83 | 84 | return connection 85 | } 86 | 87 | const getDatabaseFilesPath = () => ({ db: '', metafiles: [] }) 88 | 89 | const engine: IEngine = { 90 | isEngineForUrl, 91 | getDatabaseName, 92 | makeUrlForDatabase, 93 | queryBuilderFactory, 94 | createConnection, 95 | isDatabaseOnFile: false, 96 | getDatabaseFilesPath, 97 | } 98 | 99 | module.exports = engine 100 | -------------------------------------------------------------------------------- /src/engines/postgresql.ts: -------------------------------------------------------------------------------- 1 | import {QueryBuilderFactory, IEngine, IQueryBuilder, IDatabaseConnection, ILogger} from "../types"; 2 | import * as connectionPool from '../databaseConnectionPool' 3 | import { Client } from 'pg' 4 | import {ENGINE_PARAM_PLACEHOLDER} from "../constants"; 5 | 6 | const isEngineForUrl = (databaseUrl: string): boolean => { 7 | return /^postgres(?:ql)?:\/\//i.test(databaseUrl) 8 | } 9 | 10 | const getDatabaseName = (databaseUrl: string): string => { 11 | const dbName = databaseUrl.match(/postgres(?:ql)?:\/\/.+(?::.+)?@.+:[0-9]+\/([^?]+)\??/i)?.pop() 12 | if (!dbName) { 13 | throw new Error(`EngineError:postgresql - databaseUrl did not match expected pattern: ${databaseUrl}`) 14 | } 15 | return dbName 16 | } 17 | 18 | const makeUrlForDatabase = (databaseUrl: string, dbName: string): string => { 19 | const url = databaseUrl.replace(/(postgres(?:ql)?:\/\/.+(?::.+)?@.+:[0-9]+\/)([^?]+)(\?.+)?/i, `$1${dbName}$3`) 20 | if (!url.includes(dbName)) { 21 | throw new Error(`EngineError:postgresql - databaseUrl did not match expected pattern: ${databaseUrl}`) 22 | } 23 | return url 24 | } 25 | 26 | const queryBuilderFactory: QueryBuilderFactory = (databaseUrl: string) => { 27 | 28 | const matches = /postgres(?:ql)?:\/\/.+(?::.+)?@.+:[0-9]+\/[^?]+(?:\?schema=(.+))?/i.exec(databaseUrl) 29 | if (!matches) { 30 | throw new Error(`EngineError:postgresql - databaseUrl did not match expected pattern: ${databaseUrl}`) 31 | } 32 | const schema = matches![1] || 'public' 33 | 34 | const queryBuilder: IQueryBuilder = { 35 | deleteAllFrom: (table) => `DELETE FROM ${schema}."${table}";`, 36 | deleteFromBy: (table, column, value) => `DELETE FROM ${schema}."${table}" where ${column}='${value}';`, 37 | selectAllFrom: (table) => `SELECT * FROM ${schema}."${table}";`, 38 | insertInto: (table, values) => { 39 | const entries = Object.entries(values) 40 | return `INSERT INTO ${schema}."${table}" (${entries.map(e => e[0]).join(',')}) VALUES ('${entries.map(e => e[1]).join("','")}')` 41 | }, 42 | updateAll: (table, values) => { 43 | const entries = Object.entries(values) 44 | return `UPDATE ${schema}."${table}" SET ${entries.map(([k, v]) => k+"='"+v+"'").join(',')};` 45 | }, 46 | dropDatabaseIfExists: (db) => `DROP DATABASE IF EXISTS ${db};`, 47 | createDatabase: (db) => `CREATE DATABASE ${db};`, 48 | transactionBegin: () => `BEGIN;`, 49 | transactionCommit: () => `COMMIT;`, 50 | transactionRollback: () => `ROLLBACK;`, 51 | setForeignKeyCheckOn: () => ``, 52 | setForeignKeyCheckOff: () => ``, 53 | dropTableIfExistsCascade: (table) => `DROP TABLE IF EXISTS ${schema}."${table}" CASCADE;`, 54 | selectAllTables: () => `SELECT tablename AS tablename FROM pg_tables WHERE schemaname = current_schema();`, 55 | } 56 | return queryBuilder 57 | } 58 | 59 | /** 60 | * convert prisma rays param placeholder characters to the engine's one 61 | */ 62 | const normalizeQuery = (query: string): string => { 63 | return query.split(ENGINE_PARAM_PLACEHOLDER).reduce((p, n, i) => `${p}$${i}${n}`) 64 | } 65 | 66 | const createConnection = async (databaseUrl: string, logger: ILogger): Promise => { 67 | const dbname = getDatabaseName(databaseUrl) 68 | const client = new Client({ 69 | connectionString: databaseUrl, 70 | }) 71 | await client.connect() 72 | 73 | const connection: IDatabaseConnection = { 74 | query: async (q, params) => { 75 | logger.query(dbname, q, params) 76 | const res = params ? await client.query(normalizeQuery(q), params) : await client.query(q) 77 | return res.rows 78 | }, 79 | execute: async (q, params) => { 80 | logger.query(dbname, q, params) 81 | params ? await client.query(normalizeQuery(q), params) : await client.query(q) 82 | }, 83 | disconnect: async () => { 84 | connectionPool.removeConnection(connection) 85 | await client.end() 86 | } 87 | } 88 | 89 | connectionPool.addConnection(connection) 90 | 91 | return connection 92 | } 93 | 94 | const getDatabaseFilesPath = () => ({ db: '', metafiles: [] }) 95 | 96 | const engine: IEngine = { 97 | isEngineForUrl, 98 | getDatabaseName, 99 | makeUrlForDatabase, 100 | queryBuilderFactory, 101 | createConnection, 102 | isDatabaseOnFile: false, 103 | getDatabaseFilesPath, 104 | } 105 | 106 | module.exports = engine 107 | -------------------------------------------------------------------------------- /src/engines/sqlite.ts: -------------------------------------------------------------------------------- 1 | import {QueryBuilderFactory, IEngine, IQueryBuilder, IDatabaseConnection, ILogger, IDatabaseTopology} from "../types"; 2 | import * as connectionPool from "../databaseConnectionPool" 3 | import * as sqlite3 from "sqlite3" 4 | import * as path from 'path' 5 | import * as fs from 'fs' 6 | import {ENGINE_PARAM_PLACEHOLDER} from "../constants"; 7 | 8 | const SQLITE3_PARAM_PLACEHOLDER = '?' 9 | 10 | const isEngineForUrl = (databaseUrl: string): boolean => { 11 | return /^file:/i.test(databaseUrl) 12 | } 13 | 14 | const getDatabaseName = (databaseUrl: string): string => { 15 | const dbFileName = databaseUrl.match(/file:.+\/(.+)/i)?.pop() 16 | if (!dbFileName) { 17 | throw new Error(`EngineError:sqlite - databaseUrl did not match expected pattern: ${databaseUrl}`) 18 | } 19 | const nameParts = dbFileName.split('.') 20 | if (nameParts.length > 1) { 21 | nameParts.pop() 22 | } 23 | return nameParts.join('.') 24 | } 25 | 26 | const makeUrlForDatabase = (databaseUrl: string, dbName: string): string => { 27 | const dbFileName = databaseUrl.match(/file:.+\/(.+)/i)?.pop() 28 | if (!dbFileName) { 29 | throw new Error(`EngineError:sqlite - databaseUrl did not match expected pattern: ${databaseUrl}`) 30 | } 31 | const nameParts = dbFileName.split('.') 32 | const suffix = nameParts.length > 1 ? nameParts.pop() : null 33 | const resultFileName = dbName + (suffix ? `.${suffix}` : '') 34 | 35 | const url = databaseUrl.replace(/(file:.+\/)(.+)/i, `$1${resultFileName}`) 36 | if (!url.includes(dbName)) { 37 | throw new Error(`EngineError:sqlite - databaseUrl did not match expected pattern: ${databaseUrl}`) 38 | } 39 | return url 40 | } 41 | 42 | const queryBuilderFactory: QueryBuilderFactory = () => { 43 | const queryBuilder: IQueryBuilder = { 44 | deleteAllFrom: (table) => `DELETE FROM ${table};`, 45 | deleteFromBy: (table, column, value) => `DELETE FROM ${table} where ${column}='${value}';`, 46 | selectAllFrom: (table) => `SELECT * FROM ${table};`, 47 | insertInto: (table, values) => { 48 | const entries = Object.entries(values) 49 | return `INSERT INTO ${table} (${entries.map(e => e[0]).join(',')}) VALUES ('${entries.map(e => e[1]).join("','")}');` 50 | }, 51 | updateAll: (table, values) => { 52 | const entries = Object.entries(values) 53 | return `UPDATE ${table} SET ${entries.map(([k, v]) => k+"='"+v+"'").join(',')};` 54 | }, 55 | dropDatabaseIfExists: () => ``, 56 | createDatabase: () => ``, 57 | transactionBegin: () => `BEGIN TRANSACTION;`, 58 | transactionCommit: () => `COMMIT TRANSACTION;`, 59 | transactionRollback: () => `ROLLBACK TRANSACTION;`, 60 | setForeignKeyCheckOn: () => `PRAGMA ignore_check_constraints = false;`, 61 | setForeignKeyCheckOff: () => `PRAGMA ignore_check_constraints = true;`, 62 | dropTableIfExistsCascade: (table) => `DROP TABLE IF EXISTS ${table};`, 63 | selectAllTables: () => `SELECT name AS tablename FROM sqlite_master WHERE type='table' AND name != 'sqlite_sequence';`, 64 | } 65 | 66 | return queryBuilder 67 | } 68 | 69 | /** 70 | * convert prisma rays param placeholder characters to the engine's one 71 | */ 72 | const normalizeQuery = (query: string): string => { 73 | return query.replace(ENGINE_PARAM_PLACEHOLDER, SQLITE3_PARAM_PLACEHOLDER) 74 | } 75 | 76 | const createConnection = async (databaseUrl: string, logger: ILogger, topology: IDatabaseTopology): Promise => { 77 | const dbname = getDatabaseName(databaseUrl) 78 | 79 | const fileUrl = getDatabaseFilesPath(databaseUrl, topology).db 80 | 81 | if (!fs.existsSync(fileUrl)) { 82 | throw new Error(`EngineError:sqlite - databaseUrl not found: ${fileUrl}`) 83 | } 84 | 85 | const client = new sqlite3.Database(fileUrl) 86 | client.serialize() 87 | 88 | const connection: IDatabaseConnection = { 89 | query: async (q, params) => { 90 | logger.query(dbname, q, params) 91 | return new Promise((resolve, reject) => { 92 | const cb = (err: any, rows: unknown[]) => { 93 | err ? reject(err) : resolve(rows) 94 | } 95 | params ? client.all(normalizeQuery(q), params, cb) : client.all(q, cb) 96 | }) 97 | }, 98 | execute: async (q, params) => { 99 | logger.query(dbname, q, params) 100 | await new Promise((resolve, reject) => { 101 | const cb = (err: any) => { 102 | err ? reject(err) : resolve() 103 | } 104 | params ? client.run(normalizeQuery(q), params, cb) : client.run(q, cb) 105 | }) 106 | }, 107 | disconnect: async () => { 108 | connectionPool.removeConnection(connection) 109 | return client.close() 110 | } 111 | } 112 | 113 | connectionPool.addConnection(connection) 114 | 115 | return connection 116 | } 117 | 118 | const getDatabaseFilesPath = (databaseUrl: string, { schemaPath }: IDatabaseTopology): { db: string, metafiles: string[] } => { 119 | const pathRelativeToPrismaSchema = databaseUrl.split(/^file:/i).pop()! 120 | const db = path.join(path.dirname(schemaPath), pathRelativeToPrismaSchema) 121 | return { 122 | db, 123 | metafiles: [ 124 | db + '-journal', 125 | ] 126 | } 127 | } 128 | 129 | const engine: IEngine = { 130 | isEngineForUrl, 131 | getDatabaseName, 132 | makeUrlForDatabase, 133 | queryBuilderFactory, 134 | createConnection, 135 | isDatabaseOnFile: true, 136 | getDatabaseFilesPath, 137 | } 138 | 139 | module.exports = engine 140 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type {InitCommand, MakeMigrationCommand, MigrateCommand, PrepareCommand, StatusCommand, PushCommand} from "./types" 2 | import processArguments from './processArguments' 3 | import verifyConfig from './verifyConfig' 4 | import { releaseConnections } from "./databaseConnectionPool" 5 | 6 | const logHelp = (func: string, description: string, options: string[][] = []) => { 7 | const argsData = options.map((pair) => pair.join(' ')).join('\n ') 8 | console.log(' ', func, ' ', description, options.length > 0 ? '\n ' : '', argsData) 9 | } 10 | 11 | const apiHelp: { [name: string]: () => unknown } = { 12 | init: () => { 13 | logHelp('init', 'Setup prisma rays for your project.') 14 | }, 15 | prepare: () => { 16 | logHelp('prepare', 'Initialize the migration system against the current existing database. Warning this will empty the database in the process.') 17 | }, 18 | makemigration: () => { 19 | logHelp('makemigration', 'Create a migration file based on your recent schema changes.', [ 20 | ['name', 'suffix to give to the created migration.'], 21 | ['blank', 'optional. allow the creation of a blank migration if no changes detected'], 22 | ['autoresolve', 'optional. Automatically handle migration operations count difference'] 23 | ]) 24 | }, 25 | migrate: () => { 26 | logHelp('migrate', 'Migrate the database up and down.', [ 27 | ['name', 'optional migration name. If provided database will migrate to the state declared in the migration.'], 28 | ['fake', 'optional flag. If set, change the migration state without applying database changes.'], 29 | ]) 30 | }, 31 | push: () => { 32 | logHelp('push', 'Reset the database to the current schema state.') 33 | }, 34 | status: () => { 35 | logHelp('status', 'log the migration and schema status against the database structure') 36 | } 37 | } 38 | 39 | const commands: { [name: string]: () => Promise } = { 40 | init: async () => { 41 | return (require('./commands/init') as InitCommand)() 42 | }, 43 | prepare: async () => { 44 | const args = processArguments() 45 | return (require('./commands/prepare') as PrepareCommand)('y' in args) 46 | }, 47 | makemigration: async () => { 48 | const args = processArguments('name') 49 | const blank = 'blank' in args 50 | const autoResolveErrors = 'autoresolve' in args 51 | return (require('./commands/makeMigration') as MakeMigrationCommand)(args.name, blank, autoResolveErrors) 52 | }, 53 | migrate: async () => { 54 | const args = processArguments() 55 | const fake = 'fake' in args 56 | return (require('./commands/migrate') as MigrateCommand)({ name: args.name, fake }) 57 | }, 58 | push: async () => { 59 | const args = processArguments() 60 | const approveReset = 'y' in args 61 | return (require('./commands/push') as PushCommand)(approveReset) 62 | }, 63 | status: async () => { 64 | return (require('./commands/status') as StatusCommand)() 65 | }, 66 | help: async () => { 67 | console.log('Commands\n') 68 | Object.values(apiHelp).forEach(api => api()) 69 | const globalArgsData = [ 70 | ['log', 'run command with verbose logging'], 71 | ['conf', 'path to your config file'], 72 | ].map((pair) => pair.join(' ')) 73 | .join('\n ') 74 | console.log('\n ', globalArgsData, '\n') 75 | } 76 | } 77 | 78 | const command: string = process.argv[2] 79 | if (!command) { 80 | throw new Error(`Command is missing. Must be one of [${Object.keys(commands).join(',')}]`) 81 | } 82 | 83 | if (!commands[command]) { 84 | throw new Error(`Unknown command "${command}". Must be one of [${Object.keys(commands).join(',')}]`) 85 | } 86 | 87 | if (command !== 'help' && 'help' in processArguments()) { 88 | apiHelp[command]() 89 | } else { 90 | const configVerification = command === 'init' ? Promise.resolve() : verifyConfig() 91 | 92 | configVerification.then(() => { 93 | return commands[command]() 94 | }).then(() => { 95 | releaseConnections().then(() => { 96 | console.log(command, 'finished successfully') 97 | }) 98 | }).catch((e) => { 99 | process.on('exit', () => console.error(e)) 100 | process.exit(1) 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /src/migrationFileUtils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import * as path from "path" 3 | import { migrationsPath } from './config' 4 | import { mkdir, readdir } from './utils' 5 | 6 | export { migrationsPath } 7 | export const getMigrationFolders = async (): Promise => { 8 | if (!fs.existsSync(migrationsPath)) { 9 | await mkdir(migrationsPath) 10 | return [] 11 | } 12 | const files = await readdir(migrationsPath) 13 | return files.filter((f) => fs.lstatSync(path.join(migrationsPath, f)).isDirectory()).sort() 14 | } 15 | -------------------------------------------------------------------------------- /src/processArguments.ts: -------------------------------------------------------------------------------- 1 | import minimist from 'minimist' 2 | 3 | const processArguments: any = minimist(process.argv.slice(2)) 4 | 5 | const assertRequiredArguments = (...requiredArguments: string[]) => { 6 | requiredArguments.forEach(arg => { 7 | const argValue = processArguments[arg] 8 | 9 | if (!(arg in processArguments)) { 10 | throw new Error(`Missing process argument "${arg}"`) 11 | } 12 | 13 | if (!argValue) { 14 | throw new Error(`Missing/invalid value for process argument "${arg}"="${argValue}"`) 15 | } 16 | }) 17 | 18 | return processArguments 19 | } 20 | 21 | 22 | export default assertRequiredArguments 23 | -------------------------------------------------------------------------------- /src/templates/migration.template.js: -------------------------------------------------------------------------------- 1 | // Migration $migrationName 2 | 3 | /* 4 | * This is your migration script, you should export an array of tuples specifying each operation's up and down function. 5 | * You may add additional non-structural changes to your database such as data processing and assigning defaults to 6 | * non-null fields before their constraints are applied. 7 | * Do not however perform structure manipulations or modify the generated raw queries. 8 | * 9 | */ 10 | 11 | module.exports = [ 12 | $operations 13 | ] 14 | -------------------------------------------------------------------------------- /src/templates/raysconfig.template.js: -------------------------------------------------------------------------------- 1 | // this is your Prisma Rays config 2 | 3 | module.exports = { 4 | /* A path to your prisma migrations directory */ 5 | migrationsDir: 'prisma/migrations', 6 | /* A path to your prisma schema file */ 7 | schemaPath: 'prisma/schema.prisma', 8 | /* A connection url to your database */ 9 | databaseUrl: 'postgresql://username:userpassword@dbhost:5432/dbname?schema=public', 10 | /* The name of your shadow database if you wish to use predefined one instead of auto-create on each make-migration process */ 11 | shadowDatabaseName: null, 12 | /* Whether to enable verbose logging by default */ 13 | verboseLogging: false, 14 | } 15 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface RaysConfig { 2 | migrationsDir: string 3 | schemaPath: string 4 | databaseUrl: string 5 | shadowDatabaseName: string | null 6 | verboseLogging: boolean 7 | } 8 | 9 | export interface IDatabaseClientApi { 10 | query: (query: string, params?: any[]) => Promise 11 | execute: (query: string, params?: any[]) => Promise 12 | } 13 | 14 | export interface IDatabaseConnection extends IDatabaseClientApi{ 15 | disconnect: () => Promise 16 | } 17 | 18 | export interface IDatabaseTopology { 19 | schemaPath: string, 20 | } 21 | 22 | export interface IEngine { 23 | /** matches the give url with the url pattern associated with the target database */ 24 | isEngineForUrl: (databaseUrl: string) => boolean 25 | /** build a valid database url based on the given databaseUrl but with different database name */ 26 | makeUrlForDatabase: (databaseUrl: string, dbName: string) => string 27 | /** extracts database name from it's url */ 28 | getDatabaseName: (databaseUrl: string) => string 29 | queryBuilderFactory: QueryBuilderFactory 30 | createConnection: (databaseUrl: string, logger: ILogger, topology: IDatabaseTopology) => Promise 31 | isDatabaseOnFile: boolean 32 | getDatabaseFilesPath: (databaseUrl: string, topology: IDatabaseTopology) => { db: string, metafiles: string[] } 33 | } 34 | 35 | export type QueryBuilderFactory = (databaseUrl: string) => IQueryBuilder 36 | 37 | export interface IQueryBuilder { 38 | deleteAllFrom: (table: string) => string 39 | deleteFromBy: (table: string, column: string, value: string) => string 40 | selectAllFrom: (table: string) => string 41 | insertInto: (table: string, values: { [column: string]: string }) => string 42 | updateAll: (table: string, values: { [column: string]: string }) => string 43 | dropDatabaseIfExists: (db: string) => string 44 | createDatabase: (db: string) => string 45 | transactionBegin: () => string 46 | transactionCommit: () => string 47 | transactionRollback: () => string 48 | setForeignKeyCheckOn: () => string 49 | setForeignKeyCheckOff: () => string 50 | dropTableIfExistsCascade: (table: string) => string 51 | selectAllTables: (db: string) => string 52 | } 53 | 54 | type MigrationOperation = (arg: { client: IDatabaseClientApi }) => Promise 55 | 56 | export type IMigrationScript = Array<[MigrationOperation, MigrationOperation]> 57 | 58 | export interface ILogger { 59 | log: (...args: any) => void 60 | error: (...args: any) => void 61 | warn: (...args: any) => void 62 | query: (...args: any) => void 63 | info: (databaseName: string, ...args: any) => void 64 | } 65 | 66 | export type InitCommand = () => Promise 67 | export type MakeMigrationCommand = (name: string, blank: boolean, autoResolveErrors: boolean) => Promise 68 | export type MigrateCommand = (arg?: { name?: string, fake?: boolean }) => Promise 69 | export type PrepareCommand = (approveReset: boolean) => Promise 70 | export type StatusCommand = () => Promise 71 | export type PushCommand = (approveReset: boolean) => Promise 72 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | 3 | const extractDataSourceBlock = (schema: string): string | undefined => 4 | schema.split('datasource db {')[1]?.split('}')[0] 5 | 6 | 7 | export const getDatabaseEngineFromSchema = (schema: string): string | undefined => { 8 | const dataSourceBlock = extractDataSourceBlock(schema) 9 | return dataSourceBlock ? /provider\s*=\s*"(.+)"/.exec(dataSourceBlock)?.pop() : undefined 10 | } 11 | 12 | export const getDatabaseUrlEnvVarNameFromSchema = (schema: string): string | undefined => { 13 | const dataSourceBlock = extractDataSourceBlock(schema) 14 | return dataSourceBlock ? /url\s*=\s*env\("(.+)"\)/.exec(dataSourceBlock)?.pop() : undefined 15 | } 16 | 17 | export const copyFile = async (source: string, dest: string) => { 18 | if (fs.existsSync(dest)) { 19 | await rm(dest) 20 | } 21 | await new Promise((resolve, reject) => fs.copyFile(source, dest, (err) => { err ? reject(err) : resolve() })) 22 | } 23 | 24 | export const writeFile = async (path: string, data: string) => { 25 | await new Promise((resolve, reject) => fs.writeFile(path, data, (err) => { err ? reject(err) : resolve() })) 26 | } 27 | 28 | export const mkdir = async (path: string, options: fs.MakeDirectoryOptions = {}) => { 29 | await new Promise((resolve, reject) => fs.mkdir(path, options, (err) => { err ? reject(err) : resolve() })) 30 | } 31 | 32 | export const rm = async (path: string, options: fs.RmOptions = {}) => { 33 | await new Promise((resolve, reject) => fs.rm(path, options, (err) => { err ? reject(err) : resolve() })) 34 | } 35 | 36 | export const rmdir = async (path: string, options: fs.RmDirOptions = {}) => { 37 | await new Promise((resolve, reject) => fs.rmdir(path, options, (err) => { err ? reject(err) : resolve() })) 38 | } 39 | 40 | export const readdir = async (path: string): Promise => { 41 | return new Promise((resolve, reject) => fs.readdir(path, (err, files) => { err ? reject(err) : resolve(files) })) 42 | } 43 | -------------------------------------------------------------------------------- /src/verifyConfig.ts: -------------------------------------------------------------------------------- 1 | import type { RaysConfig } from './types' 2 | import * as path from 'path' 3 | import * as fs from 'fs' 4 | import {PrismaClient} from "@prisma/client" 5 | import processArguments from './processArguments' 6 | import { DEFAULT_CONFIG_FILE_NAME, UTF8 } from './constants' 7 | import { getDatabaseUrlEnvVarNameFromSchema, getDatabaseEngineFromSchema } from './utils' 8 | import * as engineProvider from './engineProvider' 9 | 10 | const configError = (msg: string) => { 11 | throw new Error('RaysConfigError: ' + msg); 12 | } 13 | 14 | const verifyMigrationsDir = ({ migrationsDir }: RaysConfig) => { 15 | if (!migrationsDir) { 16 | configError('Missing config value for migrationsDir') 17 | } 18 | } 19 | 20 | const verifySchemaPath = ({ schemaPath }: RaysConfig) => { 21 | if (!schemaPath) { 22 | configError('Missing config value for schemaPath') 23 | } 24 | 25 | const resolved = path.resolve(schemaPath) 26 | if (!fs.existsSync(resolved)) { 27 | configError(`Bad schemaPath value, file doesn\'t exists: ${resolved}`) 28 | } 29 | 30 | const schema = fs.readFileSync(resolved, UTF8) 31 | if (!getDatabaseUrlEnvVarNameFromSchema(schema)) { 32 | const demoSnippet = [ 33 | 'datasource db {', 34 | ' provider = "provider"', 35 | ' url = env("DATABASE_URL")', 36 | '}' 37 | ] 38 | configError('Bad prisma schema file, your schema must use an environment variable to acquire the database url. e.g:\n'+demoSnippet.join('\n')) 39 | } 40 | 41 | const dbProvider = getDatabaseEngineFromSchema(schema) 42 | if (!engineProvider.ALLOWED_ENGINES.includes(dbProvider!)) { 43 | configError('Bad prisma schema file, your schema specifies an unsupported database provider: "'+dbProvider +'", must be one of '+engineProvider.ALLOWED_ENGINES.join(', ')) 44 | } 45 | } 46 | 47 | const verifyDatabaseUrl = async ({ databaseUrl }: RaysConfig) => { 48 | if (!databaseUrl) { 49 | configError('Missing config value for databaseUrl') 50 | } 51 | 52 | const prisma = new PrismaClient({ 53 | datasources: { 54 | db: { 55 | url: databaseUrl 56 | } 57 | } 58 | }) 59 | try { 60 | await prisma.$connect() 61 | await prisma.$disconnect() 62 | } catch (e: any) { 63 | configError('Bad databaseUrl value, unable to connect to database:\n' + databaseUrl + '\n' + e.message) 64 | } 65 | } 66 | 67 | const verifyShadowDatabaseName = async ({ shadowDatabaseName, databaseUrl }: RaysConfig) => { 68 | if (!shadowDatabaseName) { 69 | return 70 | } 71 | 72 | const url = engineProvider.engineFor(databaseUrl).makeUrlForDatabase(databaseUrl, shadowDatabaseName) 73 | 74 | const prisma = new PrismaClient({ 75 | datasources: { 76 | db: { 77 | url, 78 | } 79 | } 80 | }) 81 | try { 82 | await prisma.$connect() 83 | await prisma.$disconnect() 84 | } catch (e: any) { 85 | configError('Bad shadowDatabaseName value, unable to connect to database:\n' + url + '\n' + e.message) 86 | } 87 | } 88 | 89 | export default async (): Promise => { 90 | const configFilePath = path.resolve(processArguments().conf || DEFAULT_CONFIG_FILE_NAME) 91 | if (!fs.existsSync(configFilePath)) { 92 | throw new Error(`Cannot file config file at: configFilePath\nDid you forget to run "npx rays init" ?`) 93 | } 94 | 95 | const config: RaysConfig = require(configFilePath) 96 | 97 | await verifyMigrationsDir(config) 98 | await verifySchemaPath(config) 99 | await verifyDatabaseUrl(config) 100 | await verifyShadowDatabaseName(config) 101 | } 102 | -------------------------------------------------------------------------------- /test/engines/mysql.spec.ts: -------------------------------------------------------------------------------- 1 | import type {IEngine} from "../../src/types"; 2 | 3 | const engine: IEngine = require('../../src/engines/mysql') 4 | 5 | const urlVariants = [ 6 | 'mysql://johndoe:pass@mysql–instance1.123456789012.us-east-1.rds.amazonaws.com:3306/raystest', 7 | 'mysql://johndoe:pass@host:3306/raystest', 8 | 'mysql://johndoe@host:3306/raystest', 9 | ] 10 | 11 | describe('MySQL engine', () => { 12 | test('should match database urls', () => { 13 | expect(urlVariants.map(engine.isEngineForUrl)).not.toContain(false) 14 | }) 15 | 16 | test('should create query builder', () => { 17 | expect(urlVariants.map((url) => { 18 | try { 19 | return engine.queryBuilderFactory(url) 20 | } catch (e) { 21 | return null 22 | } 23 | })).not.toContain(null) 24 | }) 25 | 26 | test('should create database urls', () => { 27 | const testdb = 'testdb' 28 | 29 | expect(urlVariants.map((url) => { 30 | try { 31 | return engine.makeUrlForDatabase(url, testdb) 32 | } catch (e) { 33 | return null 34 | } 35 | })).toEqual(urlVariants.map((url) => 36 | url.replace('raystest', testdb))) 37 | }) 38 | 39 | test('should get database name', () => { 40 | expect(urlVariants.map((url) => { 41 | try { 42 | return engine.getDatabaseName(url) 43 | } catch (e) { 44 | return null 45 | } 46 | })).toEqual(urlVariants.map(() => 'raystest')) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/engines/postgresql.spec.ts: -------------------------------------------------------------------------------- 1 | import type {IEngine} from "../../src/types"; 2 | 3 | const engine: IEngine = require('../../src/engines/postgresql') 4 | 5 | const urlVariants = [ 6 | 'postgres://user:pass@localhost:5432/raystest?schema=public', 7 | 'postgresql://user:pass@localhost:8888/raystest?schema=omega', 8 | 'postgresql://user:pass@localhost:2345/raystest', 9 | 'postgres://user:pass@localhost:5432/raystest', 10 | 'postgres://user@localhost:5432/raystest?schema=public', 11 | 'postgresql://user@localhost:8888/raystest?schema=omega', 12 | 'postgresql://user@localhost:2345/raystest', 13 | 'postgres://user:pass@localhost:5432/raystest', 14 | ] 15 | 16 | describe('PostgreSQL engine', () => { 17 | test('should match database urls', () => { 18 | expect(urlVariants.map(engine.isEngineForUrl)).not.toContain(false) 19 | }) 20 | 21 | test('should create query builder', () => { 22 | expect(urlVariants.map((url) => { 23 | try { 24 | return engine.queryBuilderFactory(url) 25 | } catch (e) { 26 | return null 27 | } 28 | })).not.toContain(null) 29 | }) 30 | 31 | test('should create database urls', () => { 32 | const testdb = 'testdb' 33 | 34 | expect(urlVariants.map((url) => { 35 | try { 36 | return engine.makeUrlForDatabase(url, testdb) 37 | } catch (e) { 38 | return null 39 | } 40 | })).toEqual(urlVariants.map((url) => 41 | url.replace('raystest', testdb))) 42 | }) 43 | 44 | test('should get database name', () => { 45 | expect(urlVariants.map((url) => { 46 | try { 47 | return engine.getDatabaseName(url) 48 | } catch (e) { 49 | return null 50 | } 51 | })).toEqual(urlVariants.map(() => 'raystest')) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/engines/sqlite.spec.ts: -------------------------------------------------------------------------------- 1 | import type {IEngine} from "../../src/types"; 2 | 3 | const engine: IEngine = require('../../src/engines/sqlite') 4 | 5 | const urlVariants = [ 6 | 'file://./my/dir/raystest.db', 7 | 'file://./my/dir/raystest', 8 | 'file://./my/dir/raystest.sqlite', 9 | 'file://./../dir/raystest.db', 10 | 'file://./../dir/raystest', 11 | 'file://./../dir/raystest.sqlite', 12 | ] 13 | 14 | describe('SQLite engine', () => { 15 | test('should match database urls', () => { 16 | expect(urlVariants.map(engine.isEngineForUrl)).not.toContain(false) 17 | }) 18 | 19 | test('should create query builder', () => { 20 | expect(urlVariants.map((url) => { 21 | try { 22 | return engine.queryBuilderFactory(url) 23 | } catch (e) { 24 | return null 25 | } 26 | })).not.toContain(null) 27 | }) 28 | 29 | test('should create database urls', () => { 30 | const testdb = 'testdb' 31 | 32 | expect(urlVariants.map((url) => { 33 | try { 34 | return engine.makeUrlForDatabase(url, testdb) 35 | } catch (e) { 36 | return null 37 | } 38 | })).toEqual(urlVariants.map((url) => 39 | url.replace('raystest', testdb))) 40 | }) 41 | 42 | test('should get database name', () => { 43 | expect(urlVariants.map((url) => { 44 | try { 45 | return engine.getDatabaseName(url) 46 | } catch (e) { 47 | return null 48 | } 49 | })).toEqual(urlVariants.map(() => 'raystest')) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /test/init.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import {withSchema} from "./testkit/testkit"; 3 | import type {RaysConfig} from "../src/types"; 4 | 5 | const schema = `` 6 | 7 | describe('Init', () => { 8 | test('Create raysconfig', withSchema({ schema, init: false }, 9 | async ({ rays, topology: { raysconfig } }) =>{ 10 | // delete previously created config file 11 | if (fs.existsSync(raysconfig)) { 12 | fs.rmSync(raysconfig) 13 | } 14 | 15 | expect(fs.existsSync(raysconfig)).toBe(false) 16 | await rays('init') 17 | expect(fs.existsSync(raysconfig)).toBe(true) 18 | })) 19 | 20 | test('raysconfig module exports', withSchema({ schema, init: false }, 21 | async ({ rays, topology: { raysconfig } }) =>{ 22 | // delete previously created config file 23 | if (fs.existsSync(raysconfig)) { 24 | fs.rmSync(raysconfig) 25 | } 26 | 27 | await rays('init') 28 | const raysConfig: RaysConfig = require(raysconfig) 29 | 30 | expect(raysConfig).toEqual({ 31 | migrationsDir: expect.any(String), 32 | schemaPath: expect.any(String), 33 | databaseUrl: expect.any(String), 34 | shadowDatabaseName: null, 35 | verboseLogging: expect.any(Boolean), 36 | }) 37 | })) 38 | }) 39 | -------------------------------------------------------------------------------- /test/make-migration.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs' 3 | import {getMigrationsDirs, verifyMigrationFiles, withSchema} from "./testkit/testkit" 4 | import {PRISMA_MIGRATIONS_TABLE, UTF8} from "../src/constants"; 5 | 6 | const schema = ` 7 | model Address { 8 | id Int @id @default(autoincrement()) 9 | houseNumber Int 10 | street String? @default("Doe") 11 | city String? @default("JD") 12 | } 13 | 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | firstname String 17 | }` 18 | 19 | const updatedSchema = ` 20 | model Address { 21 | id Int @id @default(autoincrement()) 22 | houseNumber Int 23 | street String? 24 | city String 25 | } 26 | 27 | model User { 28 | id Int @id @default(autoincrement()) 29 | firstname String 30 | lastname String? @default("Doe") 31 | }` 32 | 33 | const updatedSchema2 = ` 34 | model Address { 35 | id Int @id @default(autoincrement()) 36 | houseNumber Int 37 | street String 38 | city String? @default("NY") 39 | } 40 | 41 | model User { 42 | id Int @id @default(autoincrement()) 43 | firstname String 44 | nickname String? @default("DJ") 45 | lastname String? @default("Doe") 46 | initials String? @default("JD") 47 | }` 48 | 49 | describe('MakeMigration', () => { 50 | test('Create single migration file', withSchema({schema}, 51 | async ({rays, topology: {migrationsDir, schema}, setSchema, raw, queryBuilder, shadowDatabaseName}) => { 52 | 53 | await raw.execute(queryBuilder.insertInto('User', { firstname: 'John' })) 54 | 55 | expect((await raw.query(queryBuilder.selectAllFrom('User')) as any[])[0]).toEqual({ 56 | id: expect.any(Number), 57 | firstname: 'John', 58 | }) 59 | 60 | setSchema(updatedSchema) 61 | 62 | await rays(`makemigration --name second --autoresolve`) 63 | 64 | // ensure migration creation 65 | const migrationDirectories = getMigrationsDirs() 66 | 67 | expect(migrationDirectories.length).toEqual(2) 68 | expect(migrationDirectories[1]).toMatch(/[0-9]+_second/) 69 | 70 | const newMigration = migrationDirectories[1] 71 | verifyMigrationFiles(newMigration) 72 | 73 | const currentSchema = fs.readFileSync(schema, UTF8) 74 | const schemaBackup = fs.readFileSync(path.join(migrationsDir, newMigration, 'migration.schema.prisma'), UTF8) 75 | expect(currentSchema).toEqual(schemaBackup) 76 | 77 | // ensure it's not applied 78 | expect((await raw.query(queryBuilder.selectAllFrom('User')) as any[])[0]).toEqual({ 79 | id: expect.any(Number), 80 | firstname: 'John', 81 | }) 82 | 83 | if (shadowDatabaseName) { 84 | // ensure shadow database is reset after creating migration files 85 | const tables = await raw.query(queryBuilder.selectAllTables(shadowDatabaseName), shadowDatabaseName) as { tablename: string }[] 86 | expect(tables).toEqual([]) 87 | } 88 | 89 | const migrations: any[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 90 | .map(m => m.migration_name) 91 | .sort() 92 | expect(migrations.length).toEqual(1) 93 | expect(migrations[0]).not.toMatch(/[0-9]+_second/) 94 | })) 95 | 96 | test('Create non-blank migration file when changes detected and blank option given', withSchema({schema}, 97 | async ({rays, topology: {migrationsDir}, setSchema}) => { 98 | setSchema(updatedSchema) 99 | 100 | await rays(`makemigration --name second --blank --autoresolve`) 101 | 102 | // ensure migration creation 103 | const migrationDirectories = getMigrationsDirs() 104 | 105 | const newMigration = migrationDirectories[1] 106 | 107 | const migrationSQL = fs.readFileSync(path.join(migrationsDir, newMigration, 'migration.sql'), UTF8) 108 | expect(migrationSQL).not.toEqual('-- This is an empty migration.') 109 | })) 110 | 111 | test('Create multiple migration files without applying', withSchema({schema}, 112 | async ({rays, topology: {migrationsDir, schema}, setSchema, raw, queryBuilder}) => { 113 | 114 | await raw.execute(queryBuilder.insertInto('User', { firstname: 'John' })) 115 | 116 | expect((await raw.query(queryBuilder.selectAllFrom('User')) as any[])[0]).toEqual({ 117 | id: expect.any(Number), 118 | firstname: 'John', 119 | }) 120 | 121 | setSchema(updatedSchema) 122 | 123 | await rays(`makemigration --name second --autoresolve`) 124 | 125 | setSchema(updatedSchema2) 126 | 127 | await rays(`makemigration --name third --autoresolve`) 128 | 129 | // ensure migration creation 130 | const migrationDirectories = getMigrationsDirs() 131 | 132 | expect(migrationDirectories.length).toEqual(3) 133 | expect(migrationDirectories[1]).toMatch(/[0-9]+_second/) 134 | expect(migrationDirectories[2]).toMatch(/[0-9]+_third/) 135 | 136 | verifyMigrationFiles(migrationDirectories[1]) 137 | 138 | const newMigration = migrationDirectories[2] 139 | verifyMigrationFiles(newMigration) 140 | 141 | const currentSchema = fs.readFileSync(schema, UTF8) 142 | const schemaBackup = fs.readFileSync(path.join(migrationsDir, newMigration, 'migration.schema.prisma'), UTF8) 143 | expect(currentSchema).toEqual(schemaBackup) 144 | 145 | // ensure it's not applied 146 | expect((await raw.query(queryBuilder.selectAllFrom('User')) as any[])[0]).toEqual({ 147 | id: expect.any(Number), 148 | firstname: 'John', 149 | }) 150 | 151 | const migrations: any[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 152 | .map(m => m.migration_name) 153 | .sort() 154 | expect(migrations.length).toEqual(1) 155 | expect(migrations[0]).not.toMatch(/[0-9]+_second/) 156 | expect(migrations[0]).not.toMatch(/[0-9]+_third/) 157 | })) 158 | 159 | test('Create blank migration file when no changes detected with blank option', withSchema({schema}, 160 | async ({rays, topology: {migrationsDir, schema}, raw, queryBuilder}) => { 161 | await rays(`makemigration --name second --blank --autoresolve`) 162 | 163 | // ensure migration creation 164 | const migrationDirectories = getMigrationsDirs() 165 | 166 | expect(migrationDirectories.length).toEqual(2) 167 | expect(migrationDirectories[1]).toMatch(/[0-9]+_second/) 168 | 169 | const newMigration = migrationDirectories[1] 170 | verifyMigrationFiles(newMigration) 171 | 172 | const currentSchema = fs.readFileSync(schema, UTF8) 173 | const schemaBackup = fs.readFileSync(path.join(migrationsDir, newMigration, 'migration.schema.prisma'), UTF8) 174 | expect(currentSchema).toEqual(schemaBackup) 175 | 176 | const blankSQL = fs.readFileSync(path.join(migrationsDir, newMigration, 'migration.sql'), UTF8) 177 | expect(blankSQL).toEqual('-- This is an empty migration.') 178 | 179 | // ensure it's not applied 180 | const migrations: any[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 181 | .map(m => m.migration_name) 182 | .sort() 183 | expect(migrations.length).toEqual(1) 184 | expect(migrations[0]).not.toMatch(/[0-9]+_second/) 185 | })) 186 | 187 | test('Do nothing when no changes detected without blank option', withSchema({schema}, 188 | async ({rays}) => { 189 | await rays(`makemigration --name second --autoresolve`) 190 | 191 | // ensure migration not created 192 | const migrationDirectories = getMigrationsDirs() 193 | 194 | expect(migrationDirectories.length).toEqual(1) 195 | expect(migrationDirectories[0]).not.toMatch(/[0-9]+_second/) 196 | })) 197 | }) 198 | -------------------------------------------------------------------------------- /test/migrate.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import {getMigrationsDirs, TestFunction, withSchema} from "./testkit/testkit" 4 | import {ENGINE_PARAM_PLACEHOLDER, PRISMA_MIGRATIONS_TABLE, UTF8} from "../src/constants"; 5 | 6 | const schema = ` 7 | model User { 8 | id Int @id @default(autoincrement()) 9 | firstname String 10 | }` 11 | 12 | const updatedSchema = ` 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | firstname String 16 | lastname String? @default("Doe") 17 | }` 18 | 19 | const updatedSchema2 = ` 20 | model User { 21 | id Int @id @default(autoincrement()) 22 | firstname String 23 | lastname String? @default("Doe") 24 | initials String? @default("JD") 25 | }` 26 | 27 | const prepareTestEnvironment = async ({ setSchema, rays, raw, queryBuilder }: Parameters[0]) => { 28 | await raw.execute(queryBuilder.insertInto('User', { firstname: 'John' })) 29 | 30 | setSchema(updatedSchema) 31 | await rays('makemigration --name second --autoresolve') 32 | 33 | setSchema(updatedSchema2) 34 | await rays('makemigration --name third --autoresolve') 35 | } 36 | 37 | const getUserRecord = async ({ raw, queryBuilder }: Parameters[0]) => { 38 | return (await raw.query(queryBuilder.selectAllFrom('User')) as any[])[0] 39 | } 40 | 41 | describe('Migrate', () => { 42 | test('Migrate to top', withSchema({ schema }, 43 | async (args) => { 44 | await prepareTestEnvironment(args) 45 | const { rays, topology: { migrationsDir, schema }, raw, queryBuilder } = args 46 | 47 | await rays('migrate') 48 | 49 | const migrations = getMigrationsDirs() 50 | 51 | // ensure schema's are aligned 52 | const currentSchema = fs.readFileSync(schema, UTF8) 53 | const migrationSchema = fs.readFileSync(path.join(migrationsDir, migrations[migrations.length - 1], 'migration.schema.prisma'), UTF8) 54 | expect(currentSchema).toEqual(migrationSchema) 55 | 56 | const appliedMigrations: string[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 57 | .map(m => m.migration_name) 58 | .sort() 59 | 60 | expect(appliedMigrations).toEqual(migrations) 61 | 62 | expect(await getUserRecord(args)).toEqual({ 63 | id: expect.any(Number), 64 | firstname: 'John', 65 | lastname: 'Doe', 66 | initials: 'JD', 67 | }) 68 | })) 69 | 70 | test('Migrate with data changes', withSchema({ schema }, 71 | async (args) => { 72 | const { rays, topology: { migrationsDir }, raw, queryBuilder } = args 73 | 74 | await raw.execute(queryBuilder.insertInto('User', { firstname: 'John' })) 75 | 76 | await rays('makemigration --name second --blank --autoresolve') 77 | 78 | const migrations = getMigrationsDirs() 79 | 80 | // add data modification script to migration file 81 | const migrationScriptPath = path.join(migrationsDir, migrations[migrations.length - 1], 'migration.js') 82 | 83 | const updateQuery = queryBuilder.updateAll('User', { 84 | firstname: ENGINE_PARAM_PLACEHOLDER 85 | }).replace(`'${ENGINE_PARAM_PLACEHOLDER}'`, ENGINE_PARAM_PLACEHOLDER) 86 | 87 | fs.writeFileSync(migrationScriptPath, 88 | // language=js 89 | `module.exports = [ 90 | [ 91 | async ({ client }) => { await client.execute(\`${updateQuery}\`, ['Jeff']) }, 92 | async ({ client }) => { await client.execute(\`${updateQuery}\`, ['Failed']) } 93 | ] 94 | ]`) 95 | 96 | await rays('migrate') 97 | 98 | // ensure migration was applied 99 | 100 | const appliedMigrations: string[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 101 | .map(m => m.migration_name) 102 | .sort() 103 | 104 | expect(appliedMigrations).toEqual(migrations) 105 | 106 | expect(await getUserRecord(args)).toEqual({ 107 | id: expect.any(Number), 108 | firstname: 'Jeff', 109 | }) 110 | })) 111 | 112 | test('Migrate up to name', withSchema({ schema }, 113 | async (args) => { 114 | await prepareTestEnvironment(args) 115 | const { rays, topology: { migrationsDir, schema }, raw, queryBuilder } = args 116 | 117 | const migrations = getMigrationsDirs() 118 | 119 | await rays(`migrate --name ${ migrations[1] }`) 120 | 121 | // ensure schema's are aligned 122 | const currentSchema = fs.readFileSync(schema, UTF8) 123 | const migrationSchema = fs.readFileSync(path.join(migrationsDir, migrations[1], 'migration.schema.prisma'), UTF8) 124 | expect(currentSchema).toEqual(migrationSchema) 125 | 126 | const appliedMigrations: string[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 127 | .map(m => m.migration_name) 128 | .sort() 129 | 130 | expect(appliedMigrations).toEqual(migrations.slice(0, 2)) 131 | 132 | expect(await getUserRecord(args)).toEqual({ 133 | id: expect.any(Number), 134 | firstname: 'John', 135 | lastname: 'Doe', 136 | }) 137 | })) 138 | 139 | test('Migrate down to name', withSchema({ schema }, 140 | async (args) => { 141 | await prepareTestEnvironment(args) 142 | const { rays, topology: { migrationsDir, schema }, raw, queryBuilder } = args 143 | 144 | await rays(`migrate`) 145 | 146 | expect(await getUserRecord(args)).toEqual({ 147 | id: expect.any(Number), 148 | firstname: 'John', 149 | lastname: 'Doe', 150 | initials: 'JD', 151 | }) 152 | 153 | const migrations = getMigrationsDirs() 154 | 155 | await rays(`migrate --name ${ migrations[1] }`) 156 | 157 | // ensure schema's are aligned 158 | const currentSchema = fs.readFileSync(schema, UTF8) 159 | const migrationSchema = fs.readFileSync(path.join(migrationsDir, migrations[1], 'migration.schema.prisma'), UTF8) 160 | expect(currentSchema).toEqual(migrationSchema) 161 | 162 | const appliedMigrations: string[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 163 | .map(m => m.migration_name) 164 | .sort() 165 | 166 | expect(appliedMigrations).toEqual(migrations.slice(0, 2)) 167 | 168 | expect(await getUserRecord(args)).toEqual({ 169 | id: expect.any(Number), 170 | firstname: 'John', 171 | lastname: 'Doe', 172 | }) 173 | })) 174 | 175 | test('Fake migration', withSchema({ schema }, 176 | async (args) => { 177 | await prepareTestEnvironment(args) 178 | const { rays, topology: { migrationsDir, schema }, raw, queryBuilder } = args 179 | 180 | await rays('migrate --fake') 181 | 182 | const migrations = getMigrationsDirs() 183 | 184 | // ensure schema's are aligned 185 | const currentSchema = fs.readFileSync(schema, UTF8) 186 | const migrationSchema = fs.readFileSync(path.join(migrationsDir, migrations[migrations.length - 1], 'migration.schema.prisma'), UTF8) 187 | expect(currentSchema).toEqual(migrationSchema) 188 | 189 | const appliedMigrations: string[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 190 | .map(m => m.migration_name) 191 | .sort() 192 | 193 | expect(appliedMigrations).toEqual(migrations) 194 | 195 | expect(await getUserRecord(args)).toEqual({ 196 | id: expect.any(Number), 197 | firstname: 'John', 198 | }) 199 | })) 200 | 201 | test('Rollback failed migration operations', withSchema({ schema }, 202 | async (args) => { 203 | 204 | const { rays, topology: { migrationsDir }, raw, queryBuilder } = args 205 | 206 | await raw.execute(queryBuilder.insertInto('User', { firstname: 'John' })) 207 | 208 | const migrationsBeforeMakeMigration = getMigrationsDirs() 209 | 210 | await rays('makemigration --name second --blank --autoresolve') 211 | 212 | const migrations = getMigrationsDirs() 213 | 214 | // add data modification script to migration file 215 | const migrationScriptPath = path.join(migrationsDir, migrations[migrations.length - 1], 'migration.js') 216 | 217 | const updateQuery = queryBuilder.updateAll('User', { 218 | firstname: ENGINE_PARAM_PLACEHOLDER 219 | }).replace(`'${ENGINE_PARAM_PLACEHOLDER}'`, ENGINE_PARAM_PLACEHOLDER) 220 | 221 | fs.writeFileSync(migrationScriptPath, 222 | // language=js 223 | `module.exports = [ 224 | [ 225 | async ({ client }) => { await client.execute('${updateQuery}', ['Jeff']) }, 226 | async ({ client }) => { await client.execute('${updateQuery}', ['Failed']) } 227 | ], 228 | [ 229 | async () => { throw new Error('failed')}, 230 | async () => {} 231 | ] 232 | ]`) 233 | 234 | let failed = false 235 | try { 236 | await rays('migrate') 237 | } catch (e) { 238 | failed = true 239 | } 240 | 241 | expect(failed).toEqual(true) 242 | 243 | // ensure migration not applied 244 | const appliedMigrations: string[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 245 | .map(m => m.migration_name) 246 | .sort() 247 | 248 | expect(appliedMigrations).toEqual(migrationsBeforeMakeMigration) 249 | 250 | expect(await getUserRecord(args)).toEqual({ 251 | id: expect.any(Number), 252 | firstname: 'Failed', 253 | }) 254 | })) 255 | 256 | test('Rollback failed down migration operations', withSchema({ schema }, 257 | async (args) => { 258 | 259 | const { rays, topology: { migrationsDir }, raw, queryBuilder } = args 260 | 261 | await raw.execute(queryBuilder.insertInto('User', { firstname: 'Jeff' })) 262 | 263 | await rays('makemigration --name second --blank --autoresolve') 264 | 265 | const migrations = getMigrationsDirs() 266 | 267 | // add data modification script to migration file 268 | const migrationScriptPath = path.join(migrationsDir, migrations[migrations.length - 1], 'migration.js') 269 | 270 | const updateQuery = queryBuilder.updateAll('User', { 271 | firstname: ENGINE_PARAM_PLACEHOLDER 272 | }).replace(`'${ENGINE_PARAM_PLACEHOLDER}'`, ENGINE_PARAM_PLACEHOLDER) 273 | 274 | fs.writeFileSync(migrationScriptPath, 275 | // language=js 276 | `module.exports = [ 277 | [ 278 | async () => {}, 279 | async () => { throw new Error('failed')} 280 | ], 281 | [ 282 | async ({ client }) => { await client.execute('${updateQuery}', ['John']) }, 283 | async ({ client }) => { await client.execute('${updateQuery}', ['Jeff']) } 284 | ] 285 | ]`) 286 | 287 | await rays('migrate') 288 | 289 | expect(await getUserRecord(args)).toEqual({ 290 | id: expect.any(Number), 291 | firstname: 'John', 292 | }) 293 | 294 | let failed = false 295 | try { 296 | await rays('migrate --name ' + migrations[0]) 297 | } catch (e) { 298 | failed = true 299 | } 300 | 301 | expect(failed).toEqual(true) 302 | 303 | // ensure reverse migration not applied 304 | const appliedMigrations: string[] = (await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) as any[]) 305 | .map(m => m.migration_name) 306 | .sort() 307 | 308 | expect(appliedMigrations).toEqual(migrations) 309 | 310 | expect(await getUserRecord(args)).toEqual({ 311 | id: expect.any(Number), 312 | firstname: 'John', 313 | }) 314 | })) 315 | }) 316 | -------------------------------------------------------------------------------- /test/prepare.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import {withSchema} from "./testkit/testkit" 4 | import {PRISMA_MIGRATIONS_TABLE} from "../src/constants"; 5 | 6 | const schema = ` 7 | model Profile { 8 | id Int @id @default(autoincrement()) 9 | bio String? 10 | userId Int @unique 11 | User User @relation(fields: [userId], references: [id]) 12 | } 13 | 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | email String @unique 17 | name String? 18 | Profile Profile? 19 | } 20 | ` 21 | 22 | describe('Prepare', () => { 23 | 24 | test('Create tables, migration & run migration', withSchema({ schema, prepare: false }, 25 | async ({ rays, topology: { migrationsDir }, exec, raw, queryBuilder }) => { 26 | if (fs.existsSync(migrationsDir)) { 27 | fs.rmdirSync(migrationsDir, {recursive: true}) 28 | } 29 | 30 | await exec(`npx prisma db push --force-reset --accept-data-loss`) 31 | await rays('prepare --y') 32 | 33 | // create tables 34 | expect(await raw.query(queryBuilder.selectAllFrom('User'))).toEqual([]) 35 | expect(await raw.query(queryBuilder.selectAllFrom('Profile'))).toEqual([]) 36 | 37 | const migrations: any[] = await raw.query(queryBuilder.selectAllFrom(PRISMA_MIGRATIONS_TABLE)) 38 | expect(migrations.length).toEqual(1) 39 | expect(migrations[0].migration_name).toMatch(/[0-9]+_init/) 40 | })) 41 | 42 | test('Fail if migrations dir not empty', withSchema({ schema, prepare: false }, 43 | async ({ rays, topology: { migrationsDir }, exec }) => { 44 | if (!fs.existsSync(migrationsDir)) { 45 | fs.mkdirSync(migrationsDir) 46 | } 47 | fs.mkdirSync(path.join(migrationsDir, 'dummy_migration')) 48 | 49 | expect(fs.readdirSync(migrationsDir).length).toBeGreaterThanOrEqual(1) 50 | 51 | let failed = false 52 | await exec(`npx prisma db push`) 53 | try { 54 | await rays('prepare --y') 55 | } catch (e) { 56 | failed = true 57 | } 58 | 59 | expect(failed).toEqual(true) 60 | })) 61 | }) 62 | -------------------------------------------------------------------------------- /test/push.spec.ts: -------------------------------------------------------------------------------- 1 | import {withSchema} from "./testkit/testkit" 2 | 3 | const schema = ` 4 | model User { 5 | id Int @id @default(autoincrement()) 6 | firstname String 7 | lastname String? @default("Doe") 8 | initials String? @default("JD") 9 | }` 10 | 11 | const updatedSchema = ` 12 | model User { 13 | id Int @id @default(autoincrement()) 14 | firstname String 15 | }` 16 | 17 | describe('Push', () => { 18 | test('Overwrite database structure', withSchema({schema}, 19 | async ({rays, setSchema, raw, queryBuilder}) => { 20 | 21 | await raw.execute(queryBuilder.insertInto('User', { firstname: 'John' })) 22 | 23 | expect((await raw.query(queryBuilder.selectAllFrom('User')) as any[])[0]).toEqual({ 24 | id: expect.any(Number), 25 | firstname: 'John', 26 | lastname: 'Doe', 27 | initials: 'JD' 28 | }) 29 | 30 | setSchema(updatedSchema) 31 | 32 | await rays(`push --y`) 33 | 34 | await raw.execute(queryBuilder.insertInto('User', { firstname: 'John' })) 35 | 36 | expect((await raw.query(queryBuilder.selectAllFrom('User')) as any[])[0]).toEqual({ 37 | id: expect.any(Number), 38 | firstname: 'John', 39 | }) 40 | })) 41 | }) 42 | -------------------------------------------------------------------------------- /test/test-project/database.template.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lirancr/prisma-rays/7f20f921e09fa3fa2ae5d57d8dcd1dada2f63928/test/test-project/database.template.db -------------------------------------------------------------------------------- /test/test-project/generate.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const prismaDir = path.join(__dirname, 'prisma') 5 | const schemaPath = path.join(prismaDir, 'schema.prisma') 6 | 7 | if (!fs.existsSync(prismaDir)) { 8 | fs.mkdirSync(prismaDir, { recursive: true }) 9 | } 10 | 11 | 12 | const schema = ` 13 | generator client { 14 | provider = "prisma-client-js" 15 | } 16 | 17 | datasource db { 18 | provider = "postgresql" 19 | url = env("DATABASE_URL") 20 | shadowDatabaseUrl = env("SHADOW_DATABASE_URL") 21 | } 22 | 23 | model User { 24 | id Int @id @default(autoincrement()) 25 | name String? 26 | } 27 | ` 28 | 29 | fs.writeFileSync(schemaPath, schema) 30 | -------------------------------------------------------------------------------- /test/test-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "script", 3 | "license": "MIT", 4 | "devDependencies": { 5 | "@types/node": "15.12.4", 6 | "ts-node": "10.2.1", 7 | "typescript": "4.4.3" 8 | }, 9 | "scripts": { 10 | "postinstall": "node ./generate.js && prisma generate", 11 | "dev": "ts-node ./script.ts" 12 | }, 13 | "dependencies": { 14 | "@prisma/client": "file:../../node_modules/@prisma/client", 15 | "prisma-rays": "file:../../", 16 | "prisma": "file:../../node_modules/prisma" 17 | }, 18 | "engines": { 19 | "node": ">=14.17.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/test-project/script.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const prisma = new PrismaClient() 4 | 5 | // A `main` function so that you can use async/await 6 | async function main() { 7 | // ... you will write your Prisma Client queries here 8 | } 9 | 10 | main() 11 | .catch(e => { 12 | throw e 13 | }) 14 | .finally(async () => { 15 | await prisma.$disconnect() 16 | }) 17 | -------------------------------------------------------------------------------- /test/test-project/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "outDir": "dist", 5 | "strict": true, 6 | "lib": ["esnext", "dom"], 7 | "esModuleInterop": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/testkit/testkit.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as fs from 'fs' 3 | import execa from 'execa' 4 | import * as engineProvider from '../../src/engineProvider' 5 | import {UTF8} from "../../src/constants" 6 | import type {IQueryBuilder} from "../../src/types"; 7 | import _ from 'lodash' 8 | 9 | const testProjectPath = path.join(__dirname, '..', 'test-project') 10 | 11 | const topology = { 12 | root: testProjectPath, 13 | migrationsDir: path.join(testProjectPath, 'prisma', 'migrations'), 14 | schema: path.join(testProjectPath, 'prisma', 'schema.prisma'), 15 | raysconfig: path.join(testProjectPath, 'raysconfig.js'), 16 | } 17 | 18 | export type TestFunction = (testkit: { 19 | exec: (cmd: string, options?: execa.Options) => Promise, 20 | rays: (cmd: string) => Promise, 21 | setSchema: (modelsSchema: string) => string, 22 | shadowDatabaseName?: string 23 | topology: { 24 | root: string, 25 | migrationsDir: string, 26 | schema: string, 27 | raysconfig: string, 28 | }, 29 | queryBuilder: IQueryBuilder 30 | raw: { 31 | query: (query: string, databaseName?: string) => Promise, 32 | execute: (query: string, databaseName?: string) => Promise 33 | } 34 | }) => Promise 35 | 36 | const exec = async (cmd: string, options: execa.Options = {}) => { 37 | await execa.command(cmd, { 38 | cwd: testProjectPath, 39 | stdio: 'inherit', 40 | extendEnv: false, 41 | ...options 42 | }) 43 | await new Promise((resolve) => setTimeout(resolve, 1000)) 44 | } 45 | 46 | const rays = (cmd: string) => exec(`npx rays ${cmd}`) 47 | 48 | const setSchema = (modelsSchema: string): string => { 49 | 50 | const schemaPath = path.join(testProjectPath, 'prisma', 'schema.prisma') 51 | 52 | const schema = ` 53 | generator client { 54 | provider = "prisma-client-js" 55 | } 56 | 57 | datasource db { 58 | provider = "${ process.env.TEST_PROVIDER || 'postgresql' }" 59 | url = env("DATABASE_URL") 60 | ${ process.env.TEST_SHADOW_DATABASE_URL ? 'shadowDatabaseUrl = env("SHADOW_DATABASE_URL")' : '' } 61 | } 62 | 63 | ${modelsSchema} 64 | ` 65 | if (!fs.existsSync(path.join(testProjectPath, 'prisma'))) { 66 | fs.mkdirSync(path.join(testProjectPath, 'prisma')) 67 | } 68 | 69 | fs.writeFileSync(schemaPath, schema) 70 | 71 | return schema 72 | } 73 | 74 | const setEnv = (env: { [k: string]: string} ): string => { 75 | const envPath = path.join(testProjectPath, '.env') 76 | const envData = Object.entries(env).map(([k, v]) => `${k}="${v}"`).join('\n') 77 | fs.writeFileSync(envPath, envData) 78 | return envData 79 | } 80 | 81 | type TestKitOptions = { 82 | init: boolean 83 | prepare: boolean, 84 | env: { [k: string]: string }, 85 | } 86 | 87 | const defaultTestkitOptions: TestKitOptions = { 88 | init: true, 89 | prepare: true, 90 | env: { 91 | PROVIDER: process.env.TEST_PROVIDER || "postgresql", 92 | DATABASE_URL: process.env.TEST_DATABASE_URL || "postgresql://postgres:root@localhost:5432/raystest?schema=public", 93 | }, 94 | } 95 | 96 | if (process.env.TEST_SHADOW_DATABASE_URL) { 97 | defaultTestkitOptions.env.SHADOW_DATABASE_URL = process.env.TEST_SHADOW_DATABASE_URL 98 | } 99 | 100 | export const withSchema = ( 101 | _options: { schema: string} & Partial, 102 | testFn: TestFunction 103 | ) => { 104 | return async (): Promise => { 105 | 106 | const testOptions: TestKitOptions = { 107 | ...defaultTestkitOptions, 108 | ..._options 109 | } 110 | 111 | setEnv(testOptions.env) 112 | setSchema(_options.schema) 113 | 114 | // delete previously created config file 115 | if (fs.existsSync(topology.raysconfig)) { 116 | fs.rmSync(topology.raysconfig) 117 | } 118 | 119 | const engine = engineProvider.engineFor(testOptions.env.DATABASE_URL) 120 | 121 | const dbTopology = { 122 | schemaPath: topology.schema, 123 | } 124 | 125 | if (engine.isDatabaseOnFile) { 126 | fs.copyFileSync( 127 | path.resolve('test','test-project','database.template.db'), 128 | engine.getDatabaseFilesPath(testOptions.env.DATABASE_URL, dbTopology).db 129 | ) 130 | } 131 | 132 | if (testOptions.init) { 133 | await rays('init') 134 | 135 | // set database url for test 136 | fs.writeFileSync(topology.raysconfig, fs.readFileSync(topology.raysconfig, UTF8) 137 | .replace( 138 | /databaseUrl: '.+',/g, 139 | `databaseUrl: '${testOptions.env.DATABASE_URL}',`)) 140 | 141 | // set verbose logging for test 142 | fs.writeFileSync(topology.raysconfig, fs.readFileSync(topology.raysconfig, UTF8) 143 | .replace( 144 | /verboseLogging: .+,/g, 145 | `verboseLogging: ${process.env.VERBOSE_LOGGING || 'false'},`)) 146 | 147 | if (process.env.TEST_SHADOW_DATABASE_NAME) { 148 | // set shadow database name for test 149 | fs.writeFileSync(topology.raysconfig, fs.readFileSync(topology.raysconfig, UTF8) 150 | .replace( 151 | /shadowDatabaseName: .+,/g, 152 | `shadowDatabaseName: '${process.env.TEST_SHADOW_DATABASE_NAME}',`)) 153 | } 154 | 155 | if (testOptions.prepare) { 156 | if (fs.existsSync(topology.migrationsDir)) { 157 | fs.rmdirSync(topology.migrationsDir, {recursive: true}) 158 | } 159 | 160 | await exec(`npx prisma db push --force-reset --accept-data-loss`) 161 | await rays('prepare --y') 162 | } 163 | } 164 | 165 | const databaseName = engine.getDatabaseName(testOptions.env.DATABASE_URL) 166 | const queryBuilder = engine.queryBuilderFactory(testOptions.env.DATABASE_URL) 167 | 168 | const databaseConnectionProvider = (_databaseName: string) => 169 | engine.createConnection(_databaseName === databaseName ? testOptions.env.DATABASE_URL : engine.makeUrlForDatabase(testOptions.env.DATABASE_URL, _databaseName), { 170 | log: () => {}, 171 | warn: console.warn, 172 | error: console.error, 173 | info: () => {}, 174 | query: () => {}, 175 | }, dbTopology) 176 | 177 | return testFn({ 178 | rays, 179 | setSchema, 180 | topology, 181 | exec, 182 | queryBuilder, 183 | shadowDatabaseName: process.env.TEST_SHADOW_DATABASE_NAME, 184 | raw: { 185 | query: async (command: string, _databaseName: string = databaseName): Promise => { 186 | const client = await databaseConnectionProvider(_databaseName) 187 | try { 188 | const res = await client.query(command) 189 | await client.disconnect() 190 | return res 191 | } catch (e) { 192 | await client.disconnect() 193 | throw e 194 | } 195 | }, 196 | execute: async (command: string, _databaseName: string = databaseName): Promise => { 197 | const client = await databaseConnectionProvider(_databaseName) 198 | try { 199 | const res = await client.execute(command) 200 | await client.disconnect() 201 | return res 202 | } catch (e) { 203 | await client.disconnect() 204 | throw e 205 | } 206 | } 207 | } 208 | }) 209 | } 210 | } 211 | 212 | export const verifyMigrationFiles = (name: string) => { 213 | expect(fs.existsSync(path.join(topology.migrationsDir, name, 'migration.js'))).toEqual(true) 214 | expect(fs.existsSync(path.join(topology.migrationsDir, name, 'migration.schema.prisma'))).toEqual(true) 215 | expect(fs.existsSync(path.join(topology.migrationsDir, name, 'migration.sql'))).toEqual(true) 216 | 217 | const migrationScript = require(path.join(topology.migrationsDir, name, 'migration.js')) 218 | expect(Array.isArray(migrationScript)).toEqual(true) 219 | if (migrationScript.length > 0) { 220 | expect(Array.isArray(migrationScript[0])).toEqual(true) 221 | expect(migrationScript[0].find((fn: any) => !_.isFunction(fn))).not.toBeDefined() 222 | } 223 | } 224 | 225 | export const getMigrationsDirs = (): string[] => { 226 | return fs.readdirSync(topology.migrationsDir) 227 | .filter((migration) => fs.statSync(path.join(topology.migrationsDir, migration)).isDirectory()) 228 | .sort() 229 | } 230 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "include": ["src/**/*"], 4 | "display": "Node 14", 5 | "compilerOptions": { 6 | "lib": ["es2020"], 7 | "module": "commonjs", 8 | "target": "es2020", 9 | "outDir": "build", 10 | "allowJs": true, 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true 15 | } 16 | } 17 | --------------------------------------------------------------------------------