├── .github ├── dependabot.yml └── workflows │ ├── dependabot-pr-automation.yml │ ├── pull-request-test.yml │ ├── release-if-new-version.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .npmrc ├── .prettierignore ├── .prettierrc.cjs ├── .tool-versions ├── .yarnrc.yml ├── CHANGELOG.md ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── RULES.md ├── eslint.config.mjs ├── example ├── .invalidprismalintrc.json ├── .invalidprismalintrcwithoutrules.json ├── .prismalintrc.json ├── README.md ├── invalid-simple.prisma ├── invalid.prisma ├── loop │ └── .prismalintrc.json ├── valid.prisma └── valid2.prisma ├── jest-setup └── unit-test-setup.js ├── jest.config.js ├── package.json ├── scripts ├── bump-version.js ├── generate-docs.js └── generate-release-notes.js ├── src ├── cli.ts ├── common │ ├── config.ts │ ├── file.test.ts │ ├── file.ts │ ├── get-prisma-schema.ts │ ├── ignore.ts │ ├── parse-rules.test.ts │ ├── parse-rules.ts │ ├── prisma.ts │ ├── regex.test.ts │ ├── regex.ts │ ├── rule-config-helpers.ts │ ├── rule.ts │ ├── snake-case.test.ts │ ├── snake-case.ts │ ├── test.ts │ └── violation.ts ├── lint-prisma-files.ts ├── lint-prisma-source-code.ts ├── output │ ├── console.ts │ ├── output-format.ts │ └── render │ │ ├── __snapshots__ │ │ ├── render-contextual.test.ts.snap │ │ ├── render-json.test.ts.snap │ │ └── render-simple.test.ts.snap │ │ ├── render-contextual.test.ts │ │ ├── render-contextual.ts │ │ ├── render-json.test.ts │ │ ├── render-json.ts │ │ ├── render-simple.test.ts │ │ ├── render-simple.ts │ │ ├── render-test-util.ts │ │ └── render-util.ts ├── rule-definitions.ts └── rules │ ├── ban-unbounded-string-type.test.ts │ ├── ban-unbounded-string-type.ts │ ├── enum-name-pascal-case.test.ts │ ├── enum-name-pascal-case.ts │ ├── enum-value-snake-case.test.ts │ ├── enum-value-snake-case.ts │ ├── field-name-camel-case.test.ts │ ├── field-name-camel-case.ts │ ├── field-name-grammatical-number.test.ts │ ├── field-name-grammatical-number.ts │ ├── field-name-mapping-snake-case.test.ts │ ├── field-name-mapping-snake-case.ts │ ├── field-order.test.ts │ ├── field-order.ts │ ├── forbid-field.test.ts │ ├── forbid-field.ts │ ├── forbid-required-ignored-field.test.ts │ ├── forbid-required-ignored-field.ts │ ├── model-name-grammatical-number.test.ts │ ├── model-name-grammatical-number.ts │ ├── model-name-mapping-snake-case.test.ts │ ├── model-name-mapping-snake-case.ts │ ├── model-name-pascal-case.test.ts │ ├── model-name-pascal-case.ts │ ├── model-name-prefix.test.ts │ ├── model-name-prefix.ts │ ├── require-default-empty-arrays.test.ts │ ├── require-default-empty-arrays.ts │ ├── require-field-index.test.ts │ ├── require-field-index.ts │ ├── require-field-type.test.ts │ ├── require-field-type.ts │ ├── require-field.test.ts │ └── require-field.ts ├── tsconfig.json ├── tsconfig.test.json └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-pr-automation.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot PR automation 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1 16 | with: 17 | github-token: '${{ secrets.GITHUB_TOKEN }}' 18 | - name: Approve all dependabot PRs 19 | run: gh pr review --approve "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | - name: Enable auto-merge for patch and minor updates 24 | if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' }} 25 | run: gh pr merge --auto --squash "$PR_URL" 26 | env: 27 | PR_URL: ${{github.event.pull_request.html_url}} 28 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 29 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-test.yml: -------------------------------------------------------------------------------- 1 | name: Run PR tests and style checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | # Cancel tests on old commits when new commits are pushed. 9 | concurrency: 10 | group: pull-request-test-${{ github.head_ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | check: 15 | timeout-minutes: 20 16 | runs-on: ubuntu-latest 17 | env: 18 | NODE_OPTIONS: '--max_old_space_size=6096' 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: '20.9.0' 24 | - run: corepack enable 25 | - run: yarn install 26 | - run: yarn tsc 27 | - run: yarn docs 28 | - run: git diff --exit-code RULES.md 29 | - run: yarn style:eslint:check 30 | - run: yarn style:prettier:check 31 | - run: yarn test 32 | -------------------------------------------------------------------------------- /.github/workflows/release-if-new-version.yml: -------------------------------------------------------------------------------- 1 | name: Release if new version 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | version-check: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | new-version: ${{ steps.version-check.outputs.new-version }} 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 2 18 | - name: Check versions 19 | id: version-check 20 | run: | 21 | git checkout HEAD -- package.json 22 | curr="$(jq -r '.version' package.json)" 23 | git checkout HEAD~1 -- package.json 24 | prev="$(jq -r '.version' package.json)" 25 | if [[ "$curr" != "$prev" ]]; then 26 | echo "new-version=$curr" >> "$GITHUB_OUTPUT" 27 | fi 28 | git checkout HEAD -- package.json 29 | 30 | maybe-release: 31 | needs: 'version-check' 32 | if: needs.version-check.outputs.new-version 33 | uses: ./.github/workflows/release.yml 34 | secrets: 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | NPM_TOKEN: 7 | required: true 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: release 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | 21 | - name: Determine new package version 22 | id: version 23 | run: | 24 | git checkout HEAD -- package.json 25 | curr="$(jq -r '.version' package.json)" 26 | echo "new-version=$curr" >> "$GITHUB_OUTPUT" 27 | 28 | - name: Set up Node.js 29 | uses: actions/setup-node@v2 30 | with: 31 | node-version: '20.9.0' 32 | 33 | - name: Enable corepack 34 | run: corepack enable 35 | 36 | - name: Install dependencies 37 | run: yarn install 38 | 39 | - name: Build 40 | run: yarn build 41 | 42 | - name: Publish to NPM 43 | env: 44 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | run: npm publish 47 | 48 | - name: Generate release notes 49 | id: release-notes 50 | run: | 51 | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) 52 | echo "notes<<$EOF" >> "$GITHUB_OUTPUT" 53 | node ./scripts/generate-release-notes.js ${{ steps.version.outputs.new-version }} >> "$GITHUB_OUTPUT" 54 | echo "$EOF" >> "$GITHUB_OUTPUT" 55 | 56 | - name: Create GitHub release 57 | uses: actions/create-release@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | tag_name: v${{ steps.version.outputs.new-version }} 62 | release_name: prisma-lint v${{ steps.version.outputs.new-version }} 63 | body: ${{ steps.release-notes.outputs.notes }} 64 | draft: false 65 | prerelease: false 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .pnp.* 3 | .yarn/* 4 | !.yarn/patches 5 | !.yarn/plugins 6 | !.yarn/releases 7 | !.yarn/sdks 8 | !.yarn/versions 9 | dist 10 | .idea 11 | **/.DS_Store 12 | tsconfig.tsbuildinfo 13 | .eslintcache 14 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | current_branch=$(git rev-parse --abbrev-ref HEAD) 5 | if [[ $current_branch = 'main' ]] 6 | then 7 | echo "Do not commit directly to the main branch!" 8 | exit 1 9 | fi 10 | 11 | echo "Checking types..." 12 | yarn build 13 | 14 | echo "Linting staged files..." 15 | yarn lint-staged 16 | 17 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.ts": ["eslint --fix", "prettier --write"], 3 | "*.{js,json}": "prettier --write" 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | dist 3 | node_modules 4 | tsconfig.tsbuildinfo 5 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | }; 5 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.9.0 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | enableGlobalCache: false 3 | nodeLinker: node-modules 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## 0.10.2 (2025-05-24) 6 | 7 | - [#683](https://github.com/loop-payments/prisma-lint/issues/683) Stop requiring defaults for `@ignored` relation fields. 8 | 9 | ## 0.10.1 (2025-04-30) 10 | 11 | - [#622](https://github.com/loop-payments/prisma-lint/issues/662) Require case-sensitive compound word match for snake case conversion. Fixes bug introduced in `0.10.0`. 12 | 13 | ## 0.10.0 (2025-03-25) 14 | 15 | - Add support for `case: 'upper'` option in `enum-value-snake-case` rule. 16 | - Improve handling of `compoundWords` for snake case rules. 17 | 18 | ## 0.9.0 (2025-03-19) 19 | 20 | - [#611](https://github.com/loop-payments/prisma-lint/issues/611) Add `nativeType` option to `require-field-type` rule to additionally enforce native DB types if needed. 21 | - Update dependencies, including `commander` to `13.1.0`. 22 | 23 | ## 0.8.0 (2025-01-17) 24 | 25 | - Add new rule `field-name-grammatical-number`. 26 | 27 | ## 0.7.0 (2024-10-17) 28 | 29 | - Add support for parsing Prisma schema directory names and globs specified in `package.json`. 30 | 31 | ## 0.6.0 (2024-08-16) 32 | 33 | - Add new rules `enum-name-pascal-case` and `enum-value-snake-case`. 34 | - Add new rule `ban-unbounded-string-type`. 35 | - Add new rules `model-name-pascal-case` and `field-name-camel-case`. 36 | 37 | Thanks to @andyjy for these new rules! 38 | 39 | ## 0.5.0 (2024-05-01) 40 | 41 | - [#388](https://github.com/loop-payments/prisma-lint/issues/388) Teach `field-name-mapping-snake-case` ignore comments about individual fields. 42 | 43 | ## 0.4.0 (2024-04-23) 44 | 45 | - [#363](https://github.com/loop-payments/prisma-lint/issues/363) Require enum and custom type fields to use snake case in `field-name-mapping-snake-case`. 46 | 47 | ## 0.3.0 (2024-04-03) 48 | 49 | - [#330](https://github.com/loop-payments/prisma-lint/issues/330) Remove `requirePrefix` option and add `requireUnderscorePrefixForIds` to `field-name-mapping-snake-case` to actually support MongoDB naming conventions. 50 | 51 | ## 0.2.0 (2024-03-29) 52 | 53 | - [#354](https://github.com/loop-payments/prisma-lint/issues/354) Add `allowlist` to `model-name-grammatical-number`. 54 | 55 | ## 0.1.3 (2024-03-23) 56 | 57 | - Upgrade `@mrleebo/prisma-ast` to get new version that supports mapped enums. 58 | 59 | ## 0.1.2 (2024-03-13) 60 | 61 | - Add `require-prefix` option to `field-name-mapping-snake-case` to support MongoDB naming conventions. 62 | 63 | ## 0.1.1 (2024-03-09) 64 | 65 | - Upgrade dependencies. 66 | 67 | ## 0.1.0 (2024-01-06) 68 | 69 | - [#275](https://github.com/loop-payments/prisma-lint/issues/275) Add new rule `require-default-empty-arrays`. 70 | 71 | ## 0.0.26 (2023-12-30) 72 | 73 | - Show violation for incorrect mapping of single-word field name. 74 | 75 | ## 0.0.25 (2023-11-25) 76 | 77 | - Avoid empty line in `simple` output format. 78 | 79 | ## 0.0.24 (2023-11-24) 80 | 81 | - Add new `-o, --output` option which accepts `simple` (the default), `none`, `contextual`, `filepath`, and `json`. 82 | 83 | ## 0.0.23 (2023-11-15) 84 | 85 | - Allow ignoring required fields with default values in `forbid-required-ignored-field`. 86 | 87 | ## 0.0.22 (2023-11-10) 88 | 89 | - Add option to pluralize snake case model names. 90 | 91 | ## 0.0.20 (2023-08-22) 92 | 93 | - Add support for reading prisma schema configuration from `package.json`. 94 | 95 | ## 0.0.19 (2023-08-04) 96 | 97 | - Add rule to forbid required ignored fields, which have [surprising side effects](https://github.com/prisma/prisma/issues/13467). 98 | 99 | ## 0.0.18 (2023-06-06) 100 | 101 | - [#59](https://github.com/loop-payments/prisma-lint/issues/59) Add support for `@map` and `@@map` without keys. 102 | - [#56](https://github.com/loop-payments/prisma-lint/issues/56) Clearer error message when configuration not found. 103 | 104 | ## 0.0.17 (2023-06-01) 105 | 106 | - Show clearer error if "rules" is missing from config. 107 | - Allow ignore parameters for the `forbid-field` rule. 108 | 109 | ## 0.0.16 (2023-05-31) 110 | 111 | - Minor tweaks to rules documentation. 112 | 113 | ## 0.0.15 (2023-05-30) 114 | 115 | - Add Loop example configuration. 116 | - Add comment to README about rules being disabled by default. 117 | - Improve error output for invalid and missing configuration files. 118 | 119 | ## 0.0.12 (2023-05-30) 120 | 121 | - Fix behavior when no CLI arg is provided. 122 | 123 | ## 0.0.11 (2023-05-30) 124 | 125 | - Add support for terminal colors and `--no-color` CLI option. 126 | 127 | ## 0.0.10 (2023-05-30) 128 | 129 | - Support automatic releases on version changes. 130 | 131 | ## 0.0.4 (2023-05-30) 132 | 133 | - Add CHANGELOG. 134 | 135 | ## 0.0.3 (2023-05-29) 136 | 137 | - Add full implementation of first version. 138 | 139 | ## 0.0.2 (2023-05-23) 140 | 141 | - Fix release contents. 142 | 143 | ## 0.0.1 (2023-05-23) 144 | 145 | - Add initial skeleton code. 146 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Developing `prisma-lint` 2 | 3 | ## Set up 4 | 5 | ```sh 6 | asdf install 7 | corepack enable 8 | asdf reshim 9 | yarn 10 | ``` 11 | 12 | ## Build & run 13 | 14 | ```sh 15 | yarn build && node ./dist/cli.js -c example/.prismalintrc.json example/valid.prisma 16 | ``` 17 | 18 | ## Test 19 | 20 | A few options: 21 | 22 | 1. To run all unit tests use `yarn test`. 23 | 2. To run a specific unit test use `yarn test `. 24 | 3. To run a local version against example test cases, see [example/README.md](./example/README.md). Feel free to add or edit examples. 25 | 4. To use a local version of `prisma-lint` within a different repository, update the other repository's `package.json` dependency as shown below, then run `yarn` and `yarn prisma-lint` within the other repository. 26 | 27 | ``` 28 | "prisma-lint": "portal:/Users/max/loop/prisma-lint", 29 | ``` 30 | 31 | ## Review 32 | 33 | To prepare a pull request for review: 34 | 35 | 1. Generate rules documentation (`RULES.md`) by running `yarn docs`. 36 | 2. Add an entry to the "Unreleased" section in `CHANGELOG.md`. Omit this step if there is no user-facing change. 37 | 3. Create a pull request. 38 | 39 | ## Release 40 | 41 | To release a new version: 42 | 43 | - Check out a new branch. 44 | - Run `yarn bump-version`. 45 | - Create a pull request and merge it. 46 | - The `release.yml` GitHub Action will publish to NPM. 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 loop-payments 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prisma-lint 2 | 3 | A linter for Prisma schema files. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | > npm install --save-dev prisma-lint 9 | # or 10 | > yarn add --dev prisma-lint 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```sh 16 | > npx prisma-lint 17 | # or 18 | > yarn prisma-lint 19 | ``` 20 | 21 | The default schema path is `prisma/schema.prisma`. If a custom schema path is specified in the field `prisma.schema` within `package.json`, that is used instead. 22 | 23 | Alternatively, you can provide one or more explicit paths as CLI arguments. These can be globs, directories, or file paths. 24 | 25 | Run `yarn prisma-lint --help` for all options. 26 | 27 | ## Rules 28 | 29 | The file [RULES.md](./RULES.md) contains documentation for each rule. All rules are disabled by default. Create a configuration file to enable the rules you'd like to enforce. 30 | 31 | ## Configuration 32 | 33 | The configuration file format is loosely based on [eslint's conventions](https://github.com/eslint/eslint#configuration). Here's an example `.prismalintrc.json`: 34 | 35 | ```json 36 | { 37 | "rules": { 38 | "field-name-mapping-snake-case": [ 39 | "error", 40 | { 41 | "compoundWords": ["S3"] 42 | } 43 | ], 44 | "field-order": [ 45 | "error", 46 | { 47 | "order": ["tenantId", "..."] 48 | } 49 | ], 50 | "forbid-required-ignored-field": ["error"], 51 | "model-name-grammatical-number": [ 52 | "error", 53 | { 54 | "style": "singular" 55 | } 56 | ], 57 | "model-name-mapping-snake-case": [ 58 | "error", 59 | { 60 | "compoundWords": ["GraphQL"] 61 | } 62 | ], 63 | "require-field-index": [ 64 | "error", 65 | { 66 | "forAllRelations": true, 67 | "forNames": ["tenantId"] 68 | } 69 | ] 70 | } 71 | } 72 | ``` 73 | 74 | See [Loop's configuration](./example/loop/.prismalintrc.json) for a more thorough example. Configuration files are loaded with [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig). 75 | 76 | ## Ignore comments 77 | 78 | Rules can be ignored with three-slash (`///`) comments inside models. 79 | 80 | To ignore all lint rules for a model and its fields: 81 | 82 | ```prisma 83 | model User { 84 | /// prisma-lint-ignore-model 85 | } 86 | ``` 87 | 88 | To ignore specific lint rules for a model and its fields: 89 | 90 | ```prisma 91 | model User { 92 | /// prisma-lint-ignore-model require-field 93 | /// prisma-lint-ignore-model require-field-type 94 | } 95 | ``` 96 | 97 | Some rules support parameterized ignore comments like this: 98 | 99 | ```prisma 100 | model User { 101 | /// prisma-lint-ignore-model require-field revisionNumber,revisionCreatedAt 102 | } 103 | ``` 104 | 105 | Omitting `revisionNumber` and `revisionCreatedAt` fields from this model will not result in a violation. Other required fields remain required. 106 | 107 | ## Output 108 | 109 | There are a few output options. 110 | 111 | ### Simple (default) 112 | 113 | ``` 114 | > yarn prisma-lint -o simple 115 | example/invalid.prisma ✖ 116 | Users 11:1 117 | error Expected singular model name. model-name-grammatical-number 118 | error Missing required fields: "createdAt". require-field 119 | Users.emailAddress 13:3 120 | error Field name must be mapped to snake case. field-name-mapping-snake-case 121 | example/valid.prisma ✔ 122 | ``` 123 | 124 | ### Contextual 125 | 126 | ``` 127 | > yarn prisma-lint -o contextual 128 | example/invalid.prisma:11:1 Users 129 | model Users { 130 | ^^^^^^^^^^^ 131 | error Expected singular model name. model-name-grammatical-number 132 | error Missing required fields: "createdAt". require-field 133 | 134 | example/invalid.prisma:13:3 Users.emailAddress 135 | emailAddress String 136 | ^^^^^^^^^^^^ 137 | error Field name must be mapped to snake case. field-name-mapping-snake-case 138 | ``` 139 | 140 | ### Filepath 141 | 142 | ``` 143 | > yarn prisma-lint -o filepath 144 | example/invalid.prisma ✖ 145 | example/valid.prisma ✔ 146 | ``` 147 | 148 | ### None 149 | 150 | ``` 151 | > yarn prisma-lint -o none 152 | ``` 153 | 154 | No output, for when you just want to use the status code. 155 | 156 | ### JSON 157 | 158 | ``` 159 | > yarn prisma-lint -o json 160 | ``` 161 | 162 | Outputs a serialized JSON object with list of violations. Useful for editor plugins. 163 | 164 | ## Contributing 165 | 166 | Pull requests are welcome. Please see [DEVELOPMENT.md](./DEVELOPMENT.md). 167 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; 3 | import typescriptEslintEslintPlugin from "@typescript-eslint/eslint-plugin"; 4 | import canonical from "eslint-plugin-canonical"; 5 | import _import from "eslint-plugin-import"; 6 | import jest from "eslint-plugin-jest"; 7 | import globals from "globals"; 8 | import tsParser from "@typescript-eslint/parser"; 9 | import path from "node:path"; 10 | import { fileURLToPath } from "node:url"; 11 | import js from "@eslint/js"; 12 | import { FlatCompat } from "@eslint/eslintrc"; 13 | 14 | const __filename = fileURLToPath(import.meta.url); 15 | const __dirname = path.dirname(__filename); 16 | const compat = new FlatCompat({ 17 | baseDirectory: __dirname, 18 | recommendedConfig: js.configs.recommended, 19 | allConfig: js.configs.all 20 | }); 21 | 22 | export default defineConfig([globalIgnores([ 23 | "**/.eslintcache", 24 | "**/.eslintrc.js", 25 | "eslint-plugin-loop", 26 | "dist", 27 | "example.config.ts", 28 | "example", 29 | "**/*.d.ts", 30 | "**/*.d.ts", 31 | "**/*.cjs", 32 | ]), { 33 | extends: fixupConfigRules(compat.extends( 34 | "plugin:@typescript-eslint/recommended", 35 | "plugin:import/recommended", 36 | "plugin:import/typescript", 37 | "prettier", 38 | )), 39 | 40 | plugins: { 41 | "@typescript-eslint": fixupPluginRules(typescriptEslintEslintPlugin), 42 | canonical, 43 | import: fixupPluginRules(_import), 44 | jest, 45 | }, 46 | 47 | languageOptions: { 48 | globals: { 49 | ...globals.node, 50 | ...globals.jest, 51 | }, 52 | 53 | parser: tsParser, 54 | ecmaVersion: 5, 55 | sourceType: "module", 56 | 57 | parserOptions: { 58 | project: "tsconfig.json", 59 | tsconfigRootDir: ".", 60 | }, 61 | }, 62 | 63 | settings: { 64 | "import/resolver": { 65 | typescript: true, 66 | node: true, 67 | }, 68 | }, 69 | 70 | rules: { 71 | "@typescript-eslint/interface-name-prefix": "off", 72 | "@typescript-eslint/explicit-function-return-type": "off", 73 | "@typescript-eslint/explicit-module-boundary-types": "off", 74 | "@typescript-eslint/no-explicit-any": "off", 75 | "@typescript-eslint/no-floating-promises": ["error"], 76 | "@typescript-eslint/no-misused-promises": ["error"], 77 | "@typescript-eslint/no-non-null-assertion": ["error"], 78 | 79 | "@typescript-eslint/no-unused-vars": ["error", { 80 | argsIgnorePattern: "^_", 81 | }], 82 | 83 | "@typescript-eslint/switch-exhaustiveness-check": "error", 84 | 85 | "canonical/filename-match-regex": ["error", { 86 | regex: "^[0-9a-z-.]+$", 87 | ignoreExporting: false, 88 | }], 89 | 90 | "import/default": "off", 91 | "import/namespace": "off", 92 | "import/no-named-as-default": "off", 93 | "import/no-named-as-default-member": "off", 94 | 95 | "import/order": ["error", { 96 | groups: ["builtin", "external", "internal", "sibling", "index"], 97 | 98 | alphabetize: { 99 | order: "asc", 100 | caseInsensitive: false, 101 | }, 102 | 103 | "newlines-between": "always-and-inside-groups", 104 | }], 105 | 106 | "jest/no-identical-title": "error", 107 | 108 | "max-len": ["error", { 109 | code: Infinity, 110 | comments: 100, 111 | ignorePattern: "( = |eslint|http|AND |src|ts-ignore|yarn)", 112 | }], 113 | 114 | "no-console": "error", 115 | "no-debugger": "error", 116 | "no-useless-catch": "error", 117 | "no-useless-return": "error", 118 | "prefer-template": "error", 119 | "sort-imports": "off", 120 | 121 | quotes: ["error", "single", { 122 | avoidEscape: true, 123 | allowTemplateLiterals: false, 124 | }], 125 | }, 126 | }]); 127 | -------------------------------------------------------------------------------- /example/.invalidprismalintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "field-name-mapping-snake-case": ["error"], 4 | "model-name-grammatical-number": ["error", { "style": "singular" }], 5 | "model-name-mapping-snake-case": [ 6 | "error", 7 | { 8 | "compoundWords": ["GraphQL"], 9 | "trimPrefixed": "Db" 10 | } 11 | ], 12 | "model-name-prefix": ["error", { "prefix": "Db" }], 13 | "require-field-index": [ 14 | "error", 15 | { 16 | "blah": 1, 17 | "forAllRelations": true, 18 | "forNames": ["tenantQid", "qid", "id", "/^.*Id$/"] 19 | } 20 | ], 21 | "require-field-type": [ 22 | "error", 23 | { 24 | "ALMOST": 1, 25 | "require": [ 26 | { 27 | "type": "DateTime", 28 | "ifName": "/At$/" 29 | } 30 | ] 31 | } 32 | ], 33 | "require-field": [ 34 | "error", 35 | { 36 | "require": [ 37 | "id", 38 | "createdAt", 39 | { 40 | "name": "revisionCreatedAt", 41 | "ifSibling": "revisionNumber" 42 | }, 43 | { 44 | "name": "revisionNumber", 45 | "ifSibling": "revisionCreatedAt" 46 | }, 47 | { 48 | "name": "currencyCode", 49 | "ifSibling": "/[A|a]mountD6$/" 50 | } 51 | ] 52 | } 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /example/.invalidprismalintrcwithoutrules.json: -------------------------------------------------------------------------------- 1 | { 2 | "model-name-prefix": ["error", { "prefix": "Db" }] 3 | } 4 | -------------------------------------------------------------------------------- /example/.prismalintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "field-name-mapping-snake-case": ["error"], 4 | "forbid-required-ignored-field": ["error"], 5 | "model-name-grammatical-number": ["error", { "style": "singular" }], 6 | "model-name-mapping-snake-case": [ 7 | "error", 8 | { 9 | "compoundWords": ["GraphQL"], 10 | "trimPrefix": "Db" 11 | } 12 | ], 13 | "model-name-prefix": ["error", { "prefix": "Db" }], 14 | "require-field-index": [ 15 | "error", 16 | { 17 | "forAllRelations": true, 18 | "forNames": ["tenantQid", "qid", "id", "/^.*Id$/"] 19 | } 20 | ], 21 | "require-field-type": [ 22 | "error", 23 | { 24 | "require": [ 25 | { 26 | "type": "DateTime", 27 | "ifName": "/At$/" 28 | } 29 | ] 30 | } 31 | ], 32 | "require-field": [ 33 | "error", 34 | { 35 | "require": [ 36 | "id", 37 | "createdAt", 38 | { 39 | "name": "revisionCreatedAt", 40 | "ifSibling": "revisionNumber" 41 | }, 42 | { 43 | "name": "revisionNumber", 44 | "ifSibling": "revisionCreatedAt" 45 | }, 46 | { 47 | "name": "currencyCode", 48 | "ifSibling": "/[A|a]mountD6$/" 49 | } 50 | ] 51 | } 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Some examples for manual testing 2 | 3 | ``` 4 | > yarn build && node ./dist/cli.js -c example/.invalidprismalintrc.json example/invalid.prisma 5 | > yarn build && node ./dist/cli.js -c example/.prismalintrc.json example/invalid.prisma 6 | > yarn build && node ./dist/cli.js -c example/.prismalintrc.json example/valid.prisma 7 | > yarn build && node ./dist/cli.js -c example/.invalidprismalintrcwithoutrules.json example/valid.prisma 8 | ``` 9 | -------------------------------------------------------------------------------- /example/invalid-simple.prisma: -------------------------------------------------------------------------------- 1 | model DbUser { 2 | id String @id 3 | fooBar String 4 | @@map(name: "users") 5 | } 6 | -------------------------------------------------------------------------------- /example/invalid.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | output = "../generated/prisma/client" 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = "fake-url" 9 | } 10 | 11 | model Users { 12 | id String @id 13 | emailAddress String 14 | tenantId String 15 | removeMe String @ignore 16 | tenant Tenant @relation(fields: [tenantId], references: [id]) 17 | @@map(name: "users") 18 | } 19 | 20 | model Tenant { 21 | id String @id 22 | name String 23 | @@map(name: "tenant") 24 | } 25 | 26 | model UserRoleFoo { 27 | id String @id 28 | @@map(name: "unexpected_snake_case") 29 | } 30 | 31 | model UserRole { 32 | id String @id 33 | userId String @map(name: "userid") 34 | // No mapping. 35 | } 36 | -------------------------------------------------------------------------------- /example/loop/.prismalintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "field-name-mapping-snake-case": [ 4 | "error", 5 | { 6 | "compoundWords": ["S3", "D6"] 7 | } 8 | ], 9 | "field-order": [ 10 | "error", 11 | { 12 | "order": ["tenantQid", "..."] 13 | } 14 | ], 15 | "forbid-field": [ 16 | "error", 17 | { 18 | "forbid": ["/^(?!.*[aA]mountD6$).*D6$/", "id"] 19 | } 20 | ], 21 | "model-name-grammatical-number": [ 22 | "error", 23 | { 24 | "style": "singular" 25 | } 26 | ], 27 | "model-name-mapping-snake-case": [ 28 | "error", 29 | { 30 | "compoundWords": ["S3", "GraphQL"], 31 | "trimPrefix": "Db" 32 | } 33 | ], 34 | "model-name-prefix": ["error", { "prefix": "Db" }], 35 | "require-field-index": [ 36 | "error", 37 | { 38 | "forAllRelations": true, 39 | "forNames": ["tenantQid"] 40 | } 41 | ], 42 | "require-field-type": [ 43 | "error", 44 | { 45 | "require": [ 46 | { 47 | "type": "BigInt", 48 | "ifName": "/[Aa]mountD6$/" 49 | }, 50 | { 51 | "type": "String", 52 | "ifName": "/Date$/" 53 | }, 54 | { 55 | "type": "DateTime", 56 | "ifName": "/At$/" 57 | } 58 | ] 59 | } 60 | ], 61 | "require-field": [ 62 | "error", 63 | { 64 | "require": [ 65 | "tenantQid", 66 | { 67 | "name": "currencyCode", 68 | "ifSibling": "/[Aa]mountD6$/" 69 | } 70 | ] 71 | } 72 | ] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /example/valid.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | output = "../generated/prisma/client" 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = "fake-url" 9 | } 10 | 11 | model DbUser { 12 | id String @id 13 | createdAt DateTime @map(name: "created_at") 14 | tenantId String @map(name: "tenant_id") 15 | tenant DbTenant @relation(fields: [tenantId], references: [id]) 16 | @@index(tenantId) 17 | @@map(name: "user") 18 | } 19 | 20 | model DbTenant { 21 | /// prisma-lint-ignore-model require-field createdAt 22 | id String @id 23 | @@map(name: "tenant") 24 | } 25 | -------------------------------------------------------------------------------- /example/valid2.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | output = "../generated/prisma/client" 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = "fake-url" 9 | } 10 | 11 | model DbUser { 12 | id String @id 13 | createdAt DateTime @map(name: "created_at") 14 | tenantId String @map(name: "tenant_id") 15 | tenant DbTenant @relation(fields: [tenantId], references: [id]) 16 | @@index(tenantId) 17 | @@map(name: "user") 18 | } 19 | 20 | model DbTenant { 21 | /// prisma-lint-ignore-model require-field createdAt 22 | id String @id 23 | @@map(name: "tenant") 24 | } 25 | -------------------------------------------------------------------------------- /jest-setup/unit-test-setup.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | chalk.level = 2; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const isDebuggerAttached = (() => { 2 | try { 3 | return !!require('inspector').url(); 4 | } catch (e) { 5 | return false; 6 | } 7 | })(); 8 | 9 | const testTimeout = () => (isDebuggerAttached ? Infinity : 60000); 10 | 11 | const unitTestConfig = { 12 | moduleDirectories: ['node_modules'], 13 | extensionsToTreatAsEsm: ['.ts'], 14 | moduleFileExtensions: ['js', 'ts'], 15 | setupFiles: ['./jest-setup/unit-test-setup.js'], 16 | roots: ['/src/'], 17 | testEnvironment: 'node', 18 | testRegex: '.*\\.test\\.ts$', 19 | displayName: { 20 | name: 'unit', 21 | color: 'blue', 22 | }, 23 | moduleNameMapper: { 24 | '^#src/(.*)\\.js$': '/src/$1', 25 | }, 26 | transform: { 27 | '^.+\\.ts$': [ 28 | 'ts-jest', 29 | { useESM: true, tsconfig: './tsconfig.test.json' }, 30 | ], 31 | }, 32 | }; 33 | 34 | export default { 35 | testTimeout: testTimeout(), 36 | projects: [unitTestConfig], 37 | }; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prisma-lint", 3 | "version": "0.10.2", 4 | "description": "A linter for Prisma schema files.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/loop-payments/prisma-lint.git" 8 | }, 9 | "license": "MIT", 10 | "author": "engineering@loop.com", 11 | "type": "module", 12 | "imports": { 13 | "#src/*": "./dist/*" 14 | }, 15 | "bin": "dist/cli.js", 16 | "files": [ 17 | "dist/**/*", 18 | "!dist/**/*.test.js" 19 | ], 20 | "scripts": { 21 | "build": "tsc", 22 | "docs": "node ./scripts/generate-docs.js", 23 | "bump-version": "node ./scripts/bump-version.js", 24 | "setup:precommit-hooks": "husky install", 25 | "style:eslint": "eslint src --fix --max-warnings=0 --cache", 26 | "style:eslint:check": "eslint src --max-warnings=0 --cache", 27 | "style:prettier": "prettier --write src", 28 | "style:prettier:check": "prettier --check src", 29 | "test": "NODE_OPTIONS=--experimental-vm-modules node ./node_modules/jest/bin/jest.js", 30 | "test:cli:invalid": "dist/cli.js fixture/invalid.prisma" 31 | }, 32 | "dependencies": { 33 | "@kejistan/enum": "^0.0.2", 34 | "@mrleebo/prisma-ast": "^0.12.0", 35 | "chalk": "^5.2.0", 36 | "commander": "^13.1.0", 37 | "cosmiconfig": "^9.0.0", 38 | "glob": "^11.0.0", 39 | "pluralize": "^8.0.0", 40 | "read-package-up": "^11.0.0", 41 | "zod": "^3.21.4" 42 | }, 43 | "devDependencies": { 44 | "@eslint/compat": "^1.2.7", 45 | "@eslint/eslintrc": "^3.3.0", 46 | "@eslint/js": "^9.22.0", 47 | "@tsconfig/esm": "^1.0.3", 48 | "@tsconfig/node20": "^20.1.2", 49 | "@tsconfig/strictest": "^2.0.1", 50 | "@types/jest": "^29.2.5", 51 | "@types/node": "^22.2.0", 52 | "@types/pluralize": "^0.0.33", 53 | "@typescript-eslint/eslint-plugin": "^8.1.0", 54 | "@typescript-eslint/parser": "^8.1.0", 55 | "eslint": "^9.22.0", 56 | "eslint-config-prettier": "^10.1.1", 57 | "eslint-import-resolver-typescript": "^4.2.2", 58 | "eslint-plugin-canonical": "^5.0.0", 59 | "eslint-plugin-import": "^2.27.5", 60 | "eslint-plugin-jest": "^28.2.0", 61 | "globals": "^16.0.0", 62 | "husky": "^9.0.7", 63 | "jest": "^29.3.1", 64 | "lint-staged": "^16.0.0", 65 | "prettier": "3.5.3", 66 | "ts-jest": "^29.1.0", 67 | "typescript": "^5.0.4" 68 | }, 69 | "packageManager": "yarn@4.1.1" 70 | } 71 | -------------------------------------------------------------------------------- /scripts/bump-version.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { execSync } from 'child_process'; 3 | 4 | // Check if the new version is provided as a command-line argument 5 | let newVersion = process.argv[2]; 6 | if (!newVersion) { 7 | // Read the current version from package.json 8 | const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); 9 | const currentVersion = packageJson.version; 10 | 11 | // Increment the patch version 12 | const [major, minor, patch] = currentVersion.split('.'); 13 | newVersion = `${major}.${minor}.${parseInt(patch) + 1}`; 14 | } 15 | 16 | // Read the current version from package.json 17 | const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); 18 | const currentVersion = packageJson.version; 19 | 20 | // Check if the new version is the same as the current version 21 | if (newVersion === currentVersion) { 22 | console.log('The new version is the same as the current version.'); 23 | } else { 24 | // Update package.json with the new version 25 | packageJson.version = newVersion; 26 | fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2)); 27 | 28 | // Create a new section in CHANGELOG.md for the new version 29 | const changelogDate = new Date().toISOString().split('T')[0]; 30 | const changelogHeader = `## ${newVersion} (${changelogDate})\n`; 31 | 32 | // Read the current content of CHANGELOG.md 33 | const changelogPath = 'CHANGELOG.md'; 34 | let changelog = fs.readFileSync(changelogPath, 'utf8'); 35 | 36 | // Check if the new version section already exists in CHANGELOG.md 37 | if (changelog.includes(`## ${newVersion}`)) { 38 | console.log( 39 | `Skipping updating ${changelogPath}. Section for version ${newVersion} already exists.`, 40 | ); 41 | } else { 42 | // Insert the new section after the first 'Unreleased' section 43 | changelog = changelog.replace( 44 | /^## Unreleased\s$/m, 45 | `## Unreleased\n\n${changelogHeader}`, 46 | ); 47 | fs.writeFileSync(changelogPath, changelog); 48 | } 49 | 50 | execSync('yarn prettier -w package.json'); 51 | execSync('git add package.json CHANGELOG.md'); 52 | execSync(`git commit -m "Bump version to ${newVersion}"`); 53 | } 54 | -------------------------------------------------------------------------------- /scripts/generate-docs.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { glob } from 'glob'; 4 | import { execSync } from 'child_process'; 5 | 6 | const rulesDirectory = 'src/rules'; 7 | const outputFile = 'RULES.md'; 8 | 9 | function trimBlankNewLines(str) { 10 | return str.replace(/^\n+/, '').replace(/\n+$/, ''); 11 | } 12 | 13 | const RULES_HEADER = ` 14 | # Rules 15 | 16 | > This document is automatically generated from the source code. Do not edit it directly. 17 | 18 | Configuration option schemas are written with [Zod]( 19 | ). 20 | 21 | `; 22 | 23 | /** 24 | * @param {string[]} exampleLines 25 | * @returns {{title: string, code: string}[]} 26 | */ 27 | function listExamples(exampleLines) { 28 | const examples = []; 29 | let currentExampleTitle = ''; 30 | let currentExampleCode = ''; 31 | for (let i = 0; i < exampleLines.length; i++) { 32 | const line = exampleLines[i]; 33 | if (line.includes('@example')) { 34 | if (currentExampleTitle !== '' && currentExampleCode !== '') { 35 | examples.push({ 36 | title: currentExampleTitle, 37 | code: trimBlankNewLines(currentExampleCode), 38 | }); 39 | } 40 | const rawTitle = line.replace('@example', '').trim(); 41 | currentExampleTitle = 42 | rawTitle === '' ? 'Default' : `With \`${rawTitle}\``; 43 | currentExampleCode = ''; 44 | } else { 45 | // Remove the first 2 spaces. 46 | if (line.trim() === '') { 47 | currentExampleCode += '\n'; 48 | continue; 49 | } 50 | if (!line.startsWith(' ')) { 51 | throw new Error( 52 | `Expected line ${i} to start with 2 spaces, but got: "${line}"`, 53 | ); 54 | } 55 | currentExampleCode += `${line.slice(2)}\n`; 56 | } 57 | } 58 | if (currentExampleTitle !== '' && currentExampleCode !== '') { 59 | examples.push({ 60 | title: currentExampleTitle, 61 | code: trimBlankNewLines(currentExampleCode), 62 | }); 63 | } 64 | return examples; 65 | } 66 | 67 | function extractRulesFromSourceCode(filePath) { 68 | const fileContent = fs.readFileSync(filePath, 'utf-8'); 69 | const ruleNameMatch = fileContent.match(/const RULE_NAME = '(.*)';/); 70 | const descriptionMatch = fileContent.match(/\/\*\*\n([\s\S]*?)\n\s*\*\//); 71 | const configMatch = fileContent.match(/const Config =([\s\S]*?;)/); 72 | 73 | if (ruleNameMatch && descriptionMatch) { 74 | const ruleName = ruleNameMatch[1]; 75 | const descriptionLines = descriptionMatch[1] 76 | .trim() 77 | .split('\n') 78 | .map((line) => line.replace(/^\s*\*\s?/, '')); 79 | const firstExampleIndex = descriptionLines.findIndex((line) => 80 | line.includes('@example'), 81 | ); 82 | const description = descriptionLines.slice(0, firstExampleIndex).join('\n'); 83 | const examples = listExamples(descriptionLines.slice(firstExampleIndex)); 84 | 85 | const configSchema = configMatch[1].trim(); 86 | 87 | return { 88 | ruleName, 89 | description, 90 | examples, 91 | configSchema, 92 | }; 93 | } 94 | 95 | return null; 96 | } 97 | 98 | const EMPTY_CONFIG_SCHEMA = 'z.object({}).strict().optional();'; 99 | 100 | function buildRulesMarkdownFile(rules) { 101 | let markdownContent = RULES_HEADER; 102 | 103 | rules.forEach((rule) => { 104 | markdownContent += `- [${ 105 | rule.ruleName 106 | }](#${rule.ruleName.toLowerCase()})\n`; 107 | }); 108 | 109 | markdownContent += '\n'; 110 | 111 | rules.forEach((rule) => { 112 | markdownContent += `## ${rule.ruleName}\n\n`; 113 | markdownContent += `${rule.description}\n\n`; 114 | if (rule.configSchema !== EMPTY_CONFIG_SCHEMA) { 115 | markdownContent += '### Configuration\n\n'; 116 | markdownContent += '```ts\n'; 117 | markdownContent += `${rule.configSchema}\n`; 118 | markdownContent += '```\n\n'; 119 | } 120 | if (rule.examples.length > 0) { 121 | markdownContent += '### Examples\n\n'; 122 | } 123 | rule.examples.forEach((example) => { 124 | markdownContent += `#### ${example.title}\n\n`; 125 | markdownContent += `\`\`\`prisma\n${example.code}\n\`\`\`\n\n`; 126 | }); 127 | }); 128 | 129 | fs.writeFileSync(outputFile, markdownContent, 'utf-8'); 130 | } 131 | 132 | function extractAndBuildRulesMarkdown() { 133 | const ruleFiles = glob.sync(`${rulesDirectory}/**/!(*.spec).ts`); 134 | 135 | const rules = ruleFiles 136 | .sort() 137 | .map((file) => { 138 | try { 139 | return extractRulesFromSourceCode(file); 140 | } catch (e) { 141 | // eslint-disable-next-line no-console 142 | console.error(`Error while processing rule file ${file}`); 143 | throw e; 144 | } 145 | }) 146 | .filter((rule) => rule !== null); 147 | 148 | buildRulesMarkdownFile(rules); 149 | 150 | execSync('yarn prettier -w RULES.md'); 151 | } 152 | 153 | extractAndBuildRulesMarkdown(); 154 | -------------------------------------------------------------------------------- /scripts/generate-release-notes.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | function trimBlankNewLines(str) { 4 | return str.replace(/^\n+/, '').replace(/\n+$/, ''); 5 | } 6 | 7 | function extractChangelogLines(changelog, startVersion) { 8 | const lines = changelog.split('\n'); 9 | let isRecording = false; 10 | const extractedLines = []; 11 | 12 | const versionHeader = `## ${startVersion}`; 13 | for (const line of lines) { 14 | if (isRecording) { 15 | if (line.startsWith('##')) { 16 | break; 17 | } 18 | extractedLines.push(line); 19 | } 20 | if (line.startsWith(versionHeader)) { 21 | isRecording = true; 22 | } 23 | } 24 | 25 | return extractedLines.join('\n'); 26 | } 27 | 28 | const changelogFile = 'CHANGELOG.md'; 29 | const startVersion = process.argv[2]; 30 | 31 | if (!startVersion) { 32 | console.error('Please provide the start version as a command-line argument.'); 33 | process.exit(1); 34 | } 35 | 36 | const changelogContent = fs.readFileSync(changelogFile, 'utf8'); 37 | const extractedLines = extractChangelogLines(changelogContent, startVersion); 38 | const trimmedLines = trimBlankNewLines(extractedLines); 39 | console.log(trimmedLines); 40 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs'; 4 | import { join as joinPath } from 'path'; 5 | 6 | import chalk from 'chalk'; 7 | import { program } from 'commander'; 8 | import { cosmiconfig } from 'cosmiconfig'; 9 | import { glob } from 'glob'; 10 | 11 | import { readPackageUp } from 'read-package-up'; 12 | 13 | import { getTruncatedFileName } from '#src/common/file.js'; 14 | import { parseRules } from '#src/common/parse-rules.js'; 15 | import { lintPrismaFiles } from '#src/lint-prisma-files.js'; 16 | import { outputToConsole } from '#src/output/console.js'; 17 | import ruleDefinitions from '#src/rule-definitions.js'; 18 | 19 | const DEFAULT_PRISMA_FILE_PATH = 'prisma/schema.prisma'; 20 | 21 | program 22 | .name('prisma-lint') 23 | .description('A linter for Prisma schema files.') 24 | .option( 25 | '-c, --config ', 26 | 'A path to a config file. ' + 27 | 'If omitted, cosmiconfig is used to search for a config file.', 28 | ) 29 | .option( 30 | '-o, --output-format ', 31 | 'Output format. Options: simple, contextual, json, filepath, none.', 32 | 'simple', 33 | ) 34 | .option('--no-color', 'Disable color output.') 35 | .option('--quiet', 'Suppress all output except for errors.') 36 | .argument( 37 | '[paths...]', 38 | 'One or more schema files, directories, or globs to lint.', 39 | DEFAULT_PRISMA_FILE_PATH, 40 | ); 41 | 42 | program.parse(); 43 | 44 | const explorer = cosmiconfig('prismalint'); 45 | 46 | const options = program.opts(); 47 | const { args } = program; 48 | 49 | const getSchemaFromPackageJson = async (cwd: string) => { 50 | const pkgJson = await readPackageUp({ cwd }); 51 | return pkgJson?.packageJson?.prisma?.schema; 52 | }; 53 | 54 | const getRootConfigResult = async () => { 55 | if (options.config != null) { 56 | const result = await explorer.load(options.config); 57 | if (result == null) { 58 | return; 59 | } 60 | return result; 61 | } 62 | 63 | const result = await explorer.search(); 64 | if (result == null) { 65 | return; 66 | } 67 | return result; 68 | }; 69 | 70 | const getPathsFromArgsOrPackageJson = async (args: string[]) => { 71 | if (args.length > 0) { 72 | return args; 73 | } 74 | const schemaFromPackageJson = await getSchemaFromPackageJson(process.cwd()); 75 | if (schemaFromPackageJson != null) { 76 | return [schemaFromPackageJson]; 77 | } 78 | return [DEFAULT_PRISMA_FILE_PATH]; 79 | }; 80 | 81 | const resolvePrismaFileNames = (paths: string[]) => { 82 | const resolvedFiles = []; 83 | 84 | for (const path of paths) { 85 | const isDirectory = fs.existsSync(path) && fs.lstatSync(path).isDirectory(); 86 | const isGlob = path.includes('*'); 87 | 88 | if (isDirectory) { 89 | const filesInDirectory = glob.sync(joinPath(path, '**/*.prisma')); 90 | resolvedFiles.push(...filesInDirectory); 91 | } else if (isGlob) { 92 | const filesMatchingGlob = glob.sync(path); 93 | resolvedFiles.push(...filesMatchingGlob); 94 | } else { 95 | resolvedFiles.push(path); 96 | } 97 | } 98 | 99 | resolvedFiles.sort(); 100 | 101 | return resolvedFiles; 102 | }; 103 | 104 | const outputParseIssues = (filepath: string, parseIssues: string[]) => { 105 | const truncatedFileName = getTruncatedFileName(filepath); 106 | // eslint-disable-next-line no-console 107 | console.error(`${truncatedFileName} ${chalk.red('✖')}`); 108 | for (const parseIssue of parseIssues) { 109 | // eslint-disable-next-line no-console 110 | console.error(` ${parseIssue.replaceAll('\n', '\n ')}`); 111 | } 112 | process.exit(1); 113 | }; 114 | 115 | const run = async () => { 116 | if (!options.color) { 117 | chalk.level = 0; 118 | } 119 | const { quiet, outputFormat } = options; 120 | const rootConfig = await getRootConfigResult(); 121 | if (rootConfig == null) { 122 | // eslint-disable-next-line no-console 123 | console.error( 124 | 'Unable to find configuration file for prisma-lint. Please create a ".prismalintrc.json" file.', 125 | ); 126 | process.exit(1); 127 | } 128 | const { rules, parseIssues } = parseRules(ruleDefinitions, rootConfig.config); 129 | if (parseIssues.length > 0) { 130 | outputParseIssues(rootConfig.filepath, parseIssues); 131 | } 132 | 133 | const paths = await getPathsFromArgsOrPackageJson(args); 134 | const fileNames = resolvePrismaFileNames(paths); 135 | const fileViolationList = await lintPrismaFiles({ 136 | rules, 137 | fileNames, 138 | }); 139 | 140 | outputToConsole(fileViolationList, outputFormat, quiet); 141 | 142 | const hasViolations = fileViolationList.some( 143 | ({ violations }) => violations.length > 0, 144 | ); 145 | if (hasViolations) { 146 | process.exit(1); 147 | } 148 | }; 149 | 150 | run().catch((err) => { 151 | // eslint-disable-next-line no-console 152 | console.error(err); 153 | // Something's wrong with prisma-lint. 154 | process.exit(2); 155 | }); 156 | -------------------------------------------------------------------------------- /src/common/config.ts: -------------------------------------------------------------------------------- 1 | export type RootConfig = { 2 | rules: Record; 3 | }; 4 | 5 | type RuleName = string; 6 | 7 | type RuleConfigValue = 8 | | RuleConfigLevel 9 | | [RuleConfigLevel] 10 | | [RuleConfigLevel, RuleConfig | undefined]; 11 | 12 | type RuleConfigLevel = 'error' | 'off'; 13 | export type RuleConfig = Record; 14 | 15 | export function getRuleLevel(value: RuleConfigValue): RuleConfigLevel { 16 | if (Array.isArray(value)) { 17 | return value[0]; 18 | } 19 | return value; 20 | } 21 | 22 | export function getRuleConfig(value: RuleConfigValue): RuleConfig { 23 | if (Array.isArray(value)) { 24 | return value[1] ?? {}; 25 | } 26 | return {}; 27 | } 28 | -------------------------------------------------------------------------------- /src/common/file.test.ts: -------------------------------------------------------------------------------- 1 | import { getTruncatedFileName } from '#src/common/file.js'; 2 | 3 | describe('get truncated file name', () => { 4 | it('returns truncated file name', () => { 5 | const result = getTruncatedFileName('src/common/file.spec.ts'); 6 | expect(result).toBe('src/common/file.spec.ts'); 7 | }); 8 | 9 | it('strips the current cwd', () => { 10 | const cwd = process.cwd(); 11 | const result = getTruncatedFileName(`${cwd}/src/common/file.spec.ts`); 12 | expect(result).toBe('src/common/file.spec.ts'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/common/file.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export function getTruncatedFileName(fileName: string) { 4 | const cwd = process.cwd(); 5 | return fileName.includes(cwd) 6 | ? path.relative(process.cwd(), fileName) 7 | : fileName; 8 | } 9 | -------------------------------------------------------------------------------- /src/common/get-prisma-schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PrismaParser, 3 | VisitorClassFactory, 4 | getSchema, 5 | } from '@mrleebo/prisma-ast'; 6 | 7 | export function getPrismaSchema(sourceCode: string) { 8 | const parser = new PrismaParser({ nodeLocationTracking: 'full' }); 9 | const VisitorClass = VisitorClassFactory(parser); 10 | const visitor = new VisitorClass(); 11 | const prismaSchema = getSchema(sourceCode, { parser, visitor }); 12 | return prismaSchema; 13 | } 14 | -------------------------------------------------------------------------------- /src/common/ignore.ts: -------------------------------------------------------------------------------- 1 | import type { Enum, Model } from '@mrleebo/prisma-ast'; 2 | 3 | import { PrismaPropertyType } from '#src/common/prisma.js'; 4 | 5 | const IGNORE_MODEL = '/// prisma-lint-ignore-model'; 6 | const IGNORE_ENUM = '/// prisma-lint-ignore-enum'; 7 | 8 | export function listIgnoreModelComments(node: Model) { 9 | const commentFields = node.properties.filter( 10 | (p: any) => p.type === PrismaPropertyType.COMMENT, 11 | ) as any[]; 12 | return commentFields 13 | .map((f) => f.text.trim()) 14 | .filter((t) => t.startsWith(IGNORE_MODEL)); 15 | } 16 | 17 | export function listIgnoreEnumComments(node: Enum) { 18 | const commentFields = node.enumerators.filter( 19 | (enumerator) => enumerator.type === 'comment', 20 | ); 21 | return commentFields 22 | .map((f) => f.text.trim()) 23 | .filter((t) => t.startsWith(IGNORE_ENUM)); 24 | } 25 | 26 | export function isModelEntirelyIgnored(ignoreComments: string[]) { 27 | return ignoreComments.includes(IGNORE_MODEL); 28 | } 29 | 30 | export function isEnumEntirelyIgnored(ignoreComments: string[]) { 31 | return ignoreComments.includes(IGNORE_ENUM); 32 | } 33 | 34 | export function isRuleEntirelyIgnored( 35 | ruleName: string, 36 | ignoreModelComments: string[], 37 | ) { 38 | return ( 39 | ignoreModelComments.includes(`${IGNORE_MODEL} ${ruleName}`) || 40 | ignoreModelComments.includes(`${IGNORE_ENUM} ${ruleName}`) 41 | ); 42 | } 43 | 44 | export function getRuleIgnoreParams(node: Model | Enum, ruleName: string) { 45 | const ignoreComments = 46 | node.type === 'model' 47 | ? listIgnoreModelComments(node) 48 | : listIgnoreEnumComments(node); 49 | const ruleIgnoreComment = ignoreComments.find((c) => 50 | c.startsWith( 51 | `${node.type === 'model' ? IGNORE_MODEL : IGNORE_ENUM} ${ruleName} `, 52 | ), 53 | ); 54 | if (ruleIgnoreComment == null) { 55 | return []; 56 | } 57 | const params = ruleIgnoreComment.split(' ').slice(-1)[0]; 58 | return params.split(',').map((p: string) => p.trim()); 59 | } 60 | -------------------------------------------------------------------------------- /src/common/parse-rules.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { parseRules } from '#src/common/parse-rules.js'; 4 | import type { ModelRuleDefinition } from '#src/common/rule.js'; 5 | 6 | describe('parse rule config list', () => { 7 | const Config = z.object({ foo: z.string() }); 8 | const fakeRule = { 9 | ruleName: 'fake-rule', 10 | configSchema: Config, 11 | create: (_, context) => { 12 | return { 13 | Model: (model) => { 14 | context.report({ model, message: 'fake-message' }); 15 | }, 16 | }; 17 | }, 18 | } satisfies ModelRuleDefinition>; 19 | 20 | const NoConfig = z.object({}).strict(); 21 | const fakeRuleWithoutConfig = { 22 | ruleName: 'fake-rule-without-config', 23 | configSchema: NoConfig, 24 | create: (_, context) => { 25 | return { 26 | Model: (model) => { 27 | context.report({ model, message: 'fake-message' }); 28 | }, 29 | }; 30 | }, 31 | } satisfies ModelRuleDefinition>; 32 | 33 | const ruleDefinitions = [fakeRule, fakeRuleWithoutConfig]; 34 | 35 | describe('valid config', () => { 36 | describe('with rule-specific config', () => { 37 | it('returns parsed config and no parse issues', () => { 38 | const result = parseRules(ruleDefinitions, { 39 | rules: { 40 | 'fake-rule': ['error', { foo: 'bar' }], 41 | }, 42 | }); 43 | expect(result.parseIssues).toEqual([]); 44 | expect(result.rules.length).toEqual(1); 45 | expect(result.rules[0].ruleDefinition.ruleName).toEqual('fake-rule'); 46 | expect(result.rules[0].ruleConfig).toEqual({ foo: 'bar' }); 47 | }); 48 | }); 49 | 50 | describe('without rule-specific config', () => { 51 | it('returns parsed config and no parse issues', () => { 52 | const result = parseRules(ruleDefinitions, { 53 | rules: { 54 | 'fake-rule-without-config': ['error'], 55 | }, 56 | }); 57 | expect(result.parseIssues).toEqual([]); 58 | expect(result.rules.length).toEqual(1); 59 | expect(result.rules[0].ruleDefinition.ruleName).toEqual( 60 | 'fake-rule-without-config', 61 | ); 62 | expect(result.rules[0].ruleConfig).toEqual({}); 63 | }); 64 | }); 65 | }); 66 | 67 | describe('invalid config', () => { 68 | it('returns parse issues', () => { 69 | const result = parseRules(ruleDefinitions, { 70 | rules: { 71 | 'fake-rule': ['error', { foo: 123 }], 72 | }, 73 | }); 74 | expect(result.parseIssues.length).toEqual(1); 75 | expect(result.parseIssues[0]).toEqual( 76 | "Failed to parse config for rule 'fake-rule':\n" + 77 | ' Expected string, received number', 78 | ); 79 | expect(result.rules.length).toEqual(0); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/common/parse-rules.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRuleConfig, 3 | getRuleLevel, 4 | type RootConfig, 5 | } from '#src/common/config.js'; 6 | import type { Rule, RuleDefinition } from '#src/common/rule.js'; 7 | 8 | export const parseRules = ( 9 | ruleDefinitions: RuleDefinition[], 10 | rootConfig: RootConfig, 11 | ): { rules: Rule[]; parseIssues: string[] } => { 12 | const rules: Rule[] = []; 13 | const parseIssues: string[] = []; 14 | const rawRuleValues = rootConfig.rules; 15 | if (rawRuleValues == null) { 16 | return { rules, parseIssues: ['Expected "rules" key in config.'] }; 17 | } 18 | const ruleDefinitionMap = new Map( 19 | ruleDefinitions.map((d) => [d.ruleName, d]), 20 | ); 21 | const sortedRuleNames = Object.keys(rawRuleValues).sort(); 22 | for (const ruleName of sortedRuleNames) { 23 | const ruleDefinition = ruleDefinitionMap.get(ruleName); 24 | if (ruleDefinition == null) { 25 | parseIssues.push(`Unable to find rule for ${ruleName}`); 26 | continue; 27 | } 28 | const rawRuleValue = rawRuleValues[ruleName]; 29 | if (getRuleLevel(rawRuleValue) === 'off') { 30 | continue; 31 | } 32 | const rawRuleConfig = getRuleConfig(rawRuleValue); 33 | const parsed = ruleDefinition.configSchema.safeParse(rawRuleConfig); 34 | if (parsed.success) { 35 | rules.push({ 36 | ruleConfig: parsed.data, 37 | ruleDefinition, 38 | }); 39 | } else { 40 | const issues = parsed.error.issues.map((issue) => issue.message); 41 | if (issues.length > 1 && issues[0] === 'Required') { 42 | issues.shift(); 43 | } 44 | const parseIssue = [ 45 | `Failed to parse config for rule '${ruleName}':`, 46 | ` ${issues.join(',')}`, 47 | ].join('\n'); 48 | parseIssues.push(parseIssue); 49 | } 50 | } 51 | return { rules, parseIssues }; 52 | }; 53 | -------------------------------------------------------------------------------- /src/common/prisma.ts: -------------------------------------------------------------------------------- 1 | import { Enum } from '@kejistan/enum'; 2 | import type { 3 | AttributeArgument, 4 | BlockAttribute, 5 | Enum as PrismaEnum, 6 | Field, 7 | KeyValue, 8 | Model, 9 | Schema, 10 | Type as PrismaCustomType, 11 | Value, 12 | } from '@mrleebo/prisma-ast'; 13 | 14 | export function listFields(model: Model): Field[] { 15 | return model.properties.filter( 16 | (property) => property.type === 'field', 17 | ) as Field[]; 18 | } 19 | export const PRISMA_SCALAR_TYPES = new Set([ 20 | 'String', 21 | 'Boolean', 22 | 'Int', 23 | 'BigInt', 24 | 'Float', 25 | 'Decimal', 26 | 'DateTime', 27 | 'Json', 28 | 'Bytes', 29 | ]); 30 | 31 | export const PrismaPropertyType = Enum({ 32 | FIELD: 'field', 33 | ATTRIBUTE: 'attribute', 34 | COMMENT: 'comment', 35 | }); 36 | 37 | export function listModelBlocks(schema: Schema) { 38 | return schema.list.filter((block): block is Model => block.type === 'model'); 39 | } 40 | 41 | export function listEnumBlocks(schema: Schema) { 42 | return schema.list.filter( 43 | (block): block is PrismaEnum => block.type === 'enum', 44 | ); 45 | } 46 | 47 | export function listCustomTypeBlocks(schema: Schema) { 48 | return schema.list.filter( 49 | (block): block is PrismaCustomType => block.type === 'type', 50 | ); 51 | } 52 | 53 | export function listAttributes(node: Model): BlockAttribute[] { 54 | const attributes = node.properties.filter( 55 | (p) => p.type === PrismaPropertyType.ATTRIBUTE, 56 | ) as BlockAttribute[]; 57 | return attributes; 58 | } 59 | 60 | type NameAttribute = AttributeArgument & { 61 | value: { key: 'name'; value: string }; 62 | }; 63 | export function getMappedName(args: AttributeArgument[]): string | undefined { 64 | const firstArg = args[0]; 65 | if (typeof firstArg.value === 'string') { 66 | return firstArg.value.replace(/"/g, ''); 67 | } 68 | const filtered = args.filter((a) => { 69 | if (typeof a !== 'object' || typeof a.value !== 'object') { 70 | return false; 71 | } 72 | if (!a.value.hasOwnProperty('key')) { 73 | return false; 74 | } 75 | const value = a.value as KeyValue; 76 | if (value.key !== 'name') { 77 | return false; 78 | } 79 | if (typeof value.value !== 'string') { 80 | return false; 81 | } 82 | return true; 83 | }) as NameAttribute[]; 84 | if (filtered.length === 0) { 85 | return; 86 | } 87 | if (filtered.length > 1) { 88 | throw Error( 89 | `Unexpected multiple name attributes! ${JSON.stringify(filtered)}`, 90 | ); 91 | } 92 | return filtered[0].value.value.replace(/"/g, ''); 93 | } 94 | 95 | export function isValue(value: Value | KeyValue): value is Value { 96 | return !isKeyValue(value); 97 | } 98 | 99 | export function isKeyValue(value: Value | KeyValue): value is KeyValue { 100 | if ( 101 | typeof value === 'object' && 102 | !Array.isArray(value) && 103 | value.type === 'keyValue' 104 | ) { 105 | return true; 106 | } 107 | 108 | return false; 109 | } 110 | 111 | export function assertValueIsStringArray(value: Value): Array { 112 | if (Array.isArray(value)) { 113 | return value as Array; 114 | } 115 | 116 | if (typeof value === 'object') { 117 | if (value.type === 'array') { 118 | return value.args; 119 | } 120 | } 121 | 122 | throw new Error(`value is not a string array ${JSON.stringify(value)}`); 123 | } 124 | 125 | export function looksLikeAssociationFieldType(fieldType: any) { 126 | if (typeof fieldType != 'string') { 127 | return false; 128 | } 129 | if (PRISMA_SCALAR_TYPES.has(fieldType)) { 130 | return false; 131 | } 132 | return true; 133 | } 134 | -------------------------------------------------------------------------------- /src/common/regex.test.ts: -------------------------------------------------------------------------------- 1 | import { isRegexOrRegexStr, toRegExp } from '#src/common/regex.js'; 2 | 3 | describe('isRegexOrRegexStr', () => { 4 | it('should return true for RegExp instance', () => { 5 | const regex = /[a-z]/; 6 | expect(isRegexOrRegexStr(regex)).toBe(true); 7 | }); 8 | 9 | it('should return true for string representing a regex', () => { 10 | const regexStr = '/[0-9]+/'; 11 | expect(isRegexOrRegexStr(regexStr)).toBe(true); 12 | }); 13 | 14 | it('should return false for other values', () => { 15 | expect(isRegexOrRegexStr('test')).toBe(false); 16 | expect(isRegexOrRegexStr(123)).toBe(false); 17 | expect(isRegexOrRegexStr({})).toBe(false); 18 | expect(isRegexOrRegexStr(null)).toBe(false); 19 | expect(isRegexOrRegexStr(undefined)).toBe(false); 20 | }); 21 | }); 22 | 23 | describe('toRegExp', () => { 24 | it('should return the same RegExp instance if passed a RegExp', () => { 25 | const regex = /[a-z]/; 26 | expect(toRegExp(regex)).toBe(regex); 27 | }); 28 | 29 | it('should convert a string representing a regex to a RegExp instance', () => { 30 | const regexStr = '/[0-9]+/'; 31 | const expectedRegExp = new RegExp('[0-9]+'); 32 | expect(toRegExp(regexStr)).toEqual(expectedRegExp); 33 | }); 34 | 35 | it('should create a RegExp from a string', () => { 36 | const stringVal = 'test'; 37 | const expectedRegExp = new RegExp('^test$'); 38 | expect(toRegExp(stringVal)).toEqual(expectedRegExp); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/common/regex.ts: -------------------------------------------------------------------------------- 1 | export const isRegexOrRegexStr = (value: any) => { 2 | if (value == null) { 3 | return false; 4 | } 5 | if (value instanceof RegExp) { 6 | return true; 7 | } 8 | if (typeof value !== 'string') { 9 | return false; 10 | } 11 | return value.startsWith('/') && value.endsWith('/'); 12 | }; 13 | 14 | export const toRegExp = (value: string | RegExp) => { 15 | if (value instanceof RegExp) { 16 | return value; 17 | } 18 | if (value.startsWith('/') && value.endsWith('/')) { 19 | return new RegExp(value.slice(1, -1)); 20 | } 21 | return new RegExp(`^${value}$`); 22 | }; 23 | -------------------------------------------------------------------------------- /src/common/rule-config-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Zod schema to be used for allowList config option 3 | */ 4 | // export const configAllowList = z 5 | // .array(z.union([z.string(), z.instanceof(RegExp)])) 6 | // .optional(); 7 | 8 | /** 9 | * Zod schema to be used for trimPrefix config option 10 | */ 11 | // export const configTrimPrefix = z 12 | // .union([ 13 | // z.string(), 14 | // z.instanceof(RegExp), 15 | // z.array(z.union([z.string(), z.instanceof(RegExp)])), 16 | // ]) 17 | // .optional(); 18 | 19 | export function matchesAllowList( 20 | value: string, 21 | allowList: (string | RegExp)[] | undefined, 22 | ) { 23 | return allowList?.some((entry) => { 24 | if (entry instanceof RegExp) { 25 | return entry.test(value); 26 | } 27 | return value === entry; 28 | }); 29 | } 30 | 31 | export function trimPrefix( 32 | value: string, 33 | trimPrefixConfig: string | RegExp | (string | RegExp)[] | undefined, 34 | ) { 35 | for (const prefix of Array.isArray(trimPrefixConfig) 36 | ? trimPrefixConfig 37 | : [trimPrefixConfig]) { 38 | if (prefix === undefined) { 39 | continue; 40 | } 41 | if (prefix instanceof RegExp) { 42 | if (value.match(prefix)) { 43 | return value.replace(prefix, ''); 44 | } 45 | continue; 46 | } 47 | if (value.startsWith(prefix)) { 48 | return value.slice(prefix.length); 49 | } 50 | } 51 | return value; 52 | } 53 | -------------------------------------------------------------------------------- /src/common/rule.ts: -------------------------------------------------------------------------------- 1 | import type { Enum, Field, Model } from '@mrleebo/prisma-ast'; 2 | 3 | import type { z } from 'zod'; 4 | 5 | import type { RuleConfig } from '#src/common/config.js'; 6 | import type { 7 | EnumViolation, 8 | FieldViolation, 9 | ModelViolation, 10 | NodeViolation, 11 | } from '#src/common/violation.js'; 12 | 13 | export type Rule = { ruleConfig: RuleConfig; ruleDefinition: RuleDefinition }; 14 | 15 | /** 16 | * Context passed to rules. 17 | */ 18 | export type RuleContext = { 19 | enumNames: Set; 20 | customTypeNames: Set; 21 | fileName: string; 22 | sourceCode: string; 23 | report: (nodeViolation: T) => void; 24 | }; 25 | 26 | export type ModelRuleDefinition = { 27 | ruleName: string; 28 | configSchema: z.ZodSchema; 29 | create: ( 30 | config: T, 31 | context: RuleContext, 32 | ) => ModelRuleInstance; 33 | }; 34 | 35 | export type ModelRuleInstance = { 36 | Model: (model: Model) => void; 37 | }; 38 | 39 | export type FieldRuleDefinition = { 40 | ruleName: string; 41 | configSchema: z.ZodSchema; 42 | create: ( 43 | config: T, 44 | context: RuleContext, 45 | ) => FieldRuleInstance; 46 | }; 47 | 48 | export type FieldRuleInstance = { 49 | Field: (model: Model, field: Field) => void; 50 | }; 51 | 52 | export type EnumRuleDefinition = { 53 | ruleName: string; 54 | configSchema: z.ZodSchema; 55 | create: (config: T, context: RuleContext) => EnumRuleInstance; 56 | }; 57 | 58 | export type EnumRuleInstance = { 59 | Enum: (enumObj: Enum) => void; 60 | }; 61 | 62 | export type RuleDefinition = 63 | | ModelRuleDefinition 64 | | FieldRuleDefinition 65 | | EnumRuleDefinition; 66 | export type RuleInstance = 67 | | ModelRuleInstance 68 | | FieldRuleInstance 69 | | EnumRuleInstance; 70 | -------------------------------------------------------------------------------- /src/common/snake-case.test.ts: -------------------------------------------------------------------------------- 1 | import { toSnakeCase } from '#src/common/snake-case.js'; 2 | 3 | describe('toSnakeCase', () => { 4 | describe('various input cases', () => { 5 | it('converts single-word string to snake case', () => { 6 | const input = 'hello'; 7 | const result = toSnakeCase(input); 8 | expect(result).toEqual('hello'); 9 | }); 10 | 11 | it('converts camel case string to snake case', () => { 12 | const input = 'camelCaseString'; 13 | const result = toSnakeCase(input); 14 | expect(result).toEqual('camel_case_string'); 15 | }); 16 | 17 | it('converts Pascal case string to snake case', () => { 18 | const input = 'PascalCaseString'; 19 | const result = toSnakeCase(input); 20 | expect(result).toEqual('pascal_case_string'); 21 | }); 22 | 23 | it('converts kebab case string to snake case', () => { 24 | const input = 'kebab-case-string'; 25 | const result = toSnakeCase(input); 26 | expect(result).toEqual('kebab_case_string'); 27 | }); 28 | 29 | it('converts snake case string to snake case', () => { 30 | const input = 'snake_case_string'; 31 | const result = toSnakeCase(input); 32 | expect(result).toEqual('snake_case_string'); 33 | }); 34 | 35 | it('converts upper snake case string to snake case', () => { 36 | const input = 'SNAKE_CASE_STRING'; 37 | const result = toSnakeCase(input); 38 | expect(result).toEqual('snake_case_string'); 39 | }); 40 | }); 41 | 42 | describe('compound words', () => { 43 | it('respects compound words', () => { 44 | const input = 'HelloWorldGraphQL'; 45 | const result = toSnakeCase(input, { 46 | compoundWords: ['GraphQL'], 47 | }); 48 | expect(result).toEqual('hello_world_graphql'); 49 | }); 50 | 51 | it('respects compound words with many two-word compound words', () => { 52 | const input = 'GameBRTicketLostSequence'; 53 | const result = toSnakeCase(input, { 54 | compoundWords: ['Q6', 'QU', 'QX', 'BR', 'LT', 'QP', 'L5', 'TK', 'QT'], 55 | }); 56 | expect(result).toEqual('game_br_ticket_lost_sequence'); 57 | }); 58 | 59 | it('respects multiple compound words', () => { 60 | const input = 'APIHelloWorldGraphQL'; 61 | const result = toSnakeCase(input, { 62 | compoundWords: ['API', 'GraphQL'], 63 | }); 64 | expect(result).toEqual('api_hello_world_graphql'); 65 | }); 66 | }); 67 | 68 | describe('upper case', () => { 69 | it('converts to upper case', () => { 70 | const input = 'helloWorld'; 71 | const result = toSnakeCase(input, { case: 'upper' }); 72 | expect(result).toEqual('HELLO_WORLD'); 73 | }); 74 | 75 | describe('compound words', () => { 76 | it('respects compound words', () => { 77 | const input = 'HelloWorldGraphQL'; 78 | const result = toSnakeCase(input, { 79 | case: 'upper', 80 | compoundWords: ['GraphQL'], 81 | }); 82 | expect(result).toEqual('HELLO_WORLD_GRAPHQL'); 83 | }); 84 | 85 | it('respects compound words like API', () => { 86 | const input = 'HelloWorldAPI'; 87 | const result = toSnakeCase(input, { 88 | case: 'upper', 89 | compoundWords: ['API'], 90 | }); 91 | expect(result).toEqual('HELLO_WORLD_API'); 92 | }); 93 | 94 | it('respects repeat compound words', () => { 95 | const input = 'APIHelloWorldAPI'; 96 | const result = toSnakeCase(input, { 97 | case: 'upper', 98 | compoundWords: ['API'], 99 | }); 100 | expect(result).toEqual('API_HELLO_WORLD_API'); 101 | }); 102 | 103 | it('respects multiple compound words', () => { 104 | const input = 'APIHelloWorldGraphQL'; 105 | const result = toSnakeCase(input, { 106 | case: 'upper', 107 | compoundWords: ['API', 'GraphQL'], 108 | }); 109 | expect(result).toEqual('API_HELLO_WORLD_GRAPHQL'); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('require prefix', () => { 115 | it('respects required prefix', () => { 116 | const input = 'FooBar'; 117 | const result = toSnakeCase(input, { 118 | requirePrefix: '_', 119 | }); 120 | expect(result).toEqual('_foo_bar'); 121 | }); 122 | }); 123 | 124 | describe('numbers', () => { 125 | it('makes numbers their own words', () => { 126 | const input = 'AmazonS3'; 127 | const result = toSnakeCase(input); 128 | expect(result).toEqual('amazon_s_3'); 129 | }); 130 | 131 | it('respects compound words with numbers', () => { 132 | const input = 'AmazonS3'; 133 | const result = toSnakeCase(input, { compoundWords: ['S3'] }); 134 | expect(result).toEqual('amazon_s3'); 135 | }); 136 | 137 | it('handles consecutive numbers', () => { 138 | const input = 'Word1234Word'; 139 | const result = toSnakeCase(input); 140 | expect(result).toEqual('word_1234_word'); 141 | }); 142 | }); 143 | 144 | it('handles non-alphanumeric characters', () => { 145 | const input = 'This@Is_A^Test'; 146 | const result = toSnakeCase(input); 147 | expect(result).toEqual('this_is_a_test'); 148 | }); 149 | 150 | describe('without prefix', () => { 151 | it('removes prefix', () => { 152 | const input = 'PrefixWord'; 153 | const result = toSnakeCase(input, { trimPrefix: 'Prefix' }); 154 | expect(result).toEqual('word'); 155 | }); 156 | 157 | it('does not remove prefix if it does not match', () => { 158 | const input = 'SomethingElsePrefixWord'; 159 | const result = toSnakeCase(input, { trimPrefix: 'Pre' }); 160 | expect(result).toEqual('something_else_prefix_word'); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/common/snake-case.ts: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize'; 2 | 3 | /** 4 | * Returns a snake case string expected based on input string, 5 | * accounting for compound words and prefix. 6 | */ 7 | export function toSnakeCase( 8 | input: string, 9 | options: { 10 | /** 11 | * The case to convert to. 12 | * Default: 'lower' 13 | */ 14 | case?: 'lower' | 'upper'; 15 | 16 | /** 17 | * A prefix to require in the input string 18 | */ 19 | requirePrefix?: string; 20 | 21 | /** 22 | * A prefix to remove from the input string 23 | * before converting to snake case. 24 | */ 25 | trimPrefix?: string; 26 | 27 | /** 28 | * A list of words to keep as a single word 29 | * when converting to snake case. For example, 30 | * "GraphQL" will be converted to "graph_ql" by default, 31 | * but if "GraphQL" is in this list, it will be converted 32 | * to "graphql". 33 | */ 34 | compoundWords?: string[]; 35 | 36 | /** 37 | * Whether to convert to singular or plural snake case. 38 | */ 39 | pluralize?: boolean; 40 | 41 | /** 42 | * A mapping from singular form to irregular plural form, 43 | * for use when `pluralize` is true. Both forms should be 44 | * in snake case. 45 | * Example: `{ bill_of_lading: "bills_of_lading" }` 46 | */ 47 | irregularPlurals?: Record; 48 | } = {}, 49 | ): string { 50 | const { trimPrefix = '', compoundWords = [] } = options; 51 | 52 | // Handle prefix 53 | let processedInput = input.startsWith(trimPrefix) 54 | ? input.slice(trimPrefix.length) 55 | : input; 56 | 57 | // Convert existing snake case to lowercase 58 | if (isUpperSnakeCase(processedInput)) { 59 | processedInput = processedInput.toLowerCase(); 60 | } 61 | 62 | // Create a map of positions where compound words are found 63 | const compoundPositions: Array<{ start: number; end: number; word: string }> = 64 | []; 65 | compoundWords.forEach((word) => { 66 | const regex = new RegExp(word, 'g'); 67 | let match; 68 | while ((match = regex.exec(processedInput)) !== null) { 69 | compoundPositions.push({ 70 | start: match.index, 71 | end: match.index + word.length, 72 | word: word.toLowerCase(), 73 | }); 74 | } 75 | }); 76 | 77 | // Convert to snake case, but protect compound words 78 | let result = ''; 79 | for (let i = 0; i < processedInput.length; i++) { 80 | const compoundWord = compoundPositions.find( 81 | (p) => i >= p.start && i < p.end, 82 | ); 83 | if (compoundWord) { 84 | if (i === compoundWord.start) { 85 | // Add underscore before compound word if needed 86 | if (i > 0 && result[result.length - 1] !== '_') { 87 | result += '_'; 88 | } 89 | result += compoundWord.word; 90 | } 91 | // Skip the rest of the compound word 92 | i = compoundWord.end - 1; 93 | continue; 94 | } 95 | 96 | const char = processedInput[i]; 97 | if (i > 0) { 98 | const prevChar = processedInput[i - 1]; 99 | if (/[A-Z]/.test(char)) { 100 | // Add underscore before capitals 101 | result += `_${char.toLowerCase()}`; 102 | } else if ( 103 | (/[a-zA-Z]/.test(prevChar) && /\d/.test(char)) || 104 | (/\d/.test(prevChar) && /[a-zA-Z]/.test(char)) 105 | ) { 106 | // Add underscore between letter and number 107 | result += `_${char.toLowerCase()}`; 108 | } else { 109 | result += char.toLowerCase(); 110 | } 111 | } else { 112 | result += char.toLowerCase(); 113 | } 114 | } 115 | 116 | // Clean up underscores 117 | result = result 118 | .replace(/[^\w]+/g, '_') 119 | .replace(/^_/, '') 120 | .replace(/_$/, '') 121 | .replace(/_+/g, '_'); 122 | 123 | if (options.pluralize) { 124 | if (options.irregularPlurals) { 125 | for (const [singular, plural] of Object.entries( 126 | options.irregularPlurals, 127 | )) { 128 | pluralize.addIrregularRule(singular, plural); 129 | } 130 | } 131 | result = pluralize(result); 132 | } 133 | 134 | if (options.case === 'upper') { 135 | result = result.toUpperCase(); 136 | } 137 | 138 | if (options.requirePrefix) { 139 | result = `${options.requirePrefix}${result}`; 140 | } 141 | 142 | return result; 143 | } 144 | 145 | function isUpperSnakeCase(input: string): boolean { 146 | return /^[A-Z0-9_]+$/.test(input); 147 | } 148 | -------------------------------------------------------------------------------- /src/common/test.ts: -------------------------------------------------------------------------------- 1 | import { type RootConfig } from '#src/common/config.js'; 2 | import { parseRules } from '#src/common/parse-rules.js'; 3 | import type { RuleDefinition } from '#src/common/rule.js'; 4 | import { lintPrismaSourceCode } from '#src/lint-prisma-source-code.js'; 5 | 6 | export async function testLintPrismaSource({ 7 | ruleDefinitions, 8 | rootConfig, 9 | fileName, 10 | sourceCode, 11 | }: { 12 | ruleDefinitions: RuleDefinition[]; 13 | rootConfig: RootConfig; 14 | fileName: string; 15 | sourceCode: string; 16 | }) { 17 | const { rules, parseIssues } = parseRules(ruleDefinitions, rootConfig); 18 | if (parseIssues.length > 0) { 19 | throw new Error( 20 | `Unable to parse test config for ${fileName}:\n${parseIssues 21 | .map((issue) => ` ${issue}`) 22 | .join('\n')}`, 23 | ); 24 | } 25 | const violations = await lintPrismaSourceCode({ 26 | rules, 27 | fileName, 28 | sourceCode, 29 | }); 30 | return violations; 31 | } 32 | -------------------------------------------------------------------------------- /src/common/violation.ts: -------------------------------------------------------------------------------- 1 | import type { Enum, Field, Model } from '@mrleebo/prisma-ast'; 2 | 3 | export type EnumViolation = { enum: Enum; message: string }; 4 | export type ModelViolation = { model: Model; message: string }; 5 | export type FieldViolation = { 6 | model: Model; 7 | field: Field; 8 | message: string; 9 | }; 10 | export type NodeViolation = EnumViolation | ModelViolation | FieldViolation; 11 | 12 | export type Violation = { 13 | ruleName: string; 14 | fileName: string; 15 | message: string; 16 | } & ( 17 | | { 18 | model: Model; 19 | field?: Field; 20 | enum?: undefined; 21 | } 22 | | { 23 | model?: undefined; 24 | field?: undefined; 25 | enum: Enum; 26 | } 27 | ); 28 | -------------------------------------------------------------------------------- /src/lint-prisma-files.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { promisify } from 'util'; 5 | 6 | import type { Rule } from '#src/common/rule.js'; 7 | 8 | import type { Violation } from '#src/common/violation.js'; 9 | import { lintPrismaSourceCode } from '#src/lint-prisma-source-code.js'; 10 | 11 | export type FileViolationList = { 12 | fileName: string; 13 | sourceCode: string; 14 | violations: Violation[]; 15 | }[]; 16 | 17 | export const lintPrismaFiles = async ({ 18 | rules, 19 | fileNames, 20 | }: { 21 | rules: Rule[]; 22 | fileNames: string[]; 23 | }): Promise => { 24 | const fileViolationList: FileViolationList = []; 25 | for (const fileName of fileNames) { 26 | const filePath = path.resolve(fileName); 27 | const sourceCode = await promisify(fs.readFile)(filePath, { 28 | encoding: 'utf8', 29 | }); 30 | const violations = lintPrismaSourceCode({ fileName, sourceCode, rules }); 31 | fileViolationList.push({ fileName, sourceCode, violations }); 32 | } 33 | return fileViolationList; 34 | }; 35 | -------------------------------------------------------------------------------- /src/lint-prisma-source-code.ts: -------------------------------------------------------------------------------- 1 | import { getPrismaSchema } from '#src/common/get-prisma-schema.js'; 2 | import { 3 | isModelEntirelyIgnored, 4 | isRuleEntirelyIgnored, 5 | listIgnoreModelComments, 6 | listIgnoreEnumComments, 7 | isEnumEntirelyIgnored, 8 | } from '#src/common/ignore.js'; 9 | import { 10 | listModelBlocks, 11 | listFields, 12 | listEnumBlocks, 13 | listCustomTypeBlocks, 14 | } from '#src/common/prisma.js'; 15 | import type { Rule, RuleInstance } from '#src/common/rule.js'; 16 | 17 | import type { Violation, NodeViolation } from '#src/common/violation.js'; 18 | 19 | type NamedRuleInstance = { ruleName: string; ruleInstance: RuleInstance }; 20 | 21 | export function lintPrismaSourceCode({ 22 | rules, 23 | fileName, 24 | sourceCode, 25 | }: { 26 | rules: Rule[]; 27 | fileName: string; 28 | sourceCode: string; 29 | }): Violation[] { 30 | // Parse source code into AST. 31 | const prismaSchema = getPrismaSchema(sourceCode); 32 | 33 | // Mutable list of violations added to by rule instances. 34 | const violations: Violation[] = []; 35 | 36 | const enums = listEnumBlocks(prismaSchema); 37 | const enumNames = new Set(enums.map((e) => e.name)); 38 | const customTypes = listCustomTypeBlocks(prismaSchema); 39 | const customTypeNames = new Set(customTypes.map((e) => e.name)); 40 | 41 | // Create rule instances. 42 | const namedRuleInstances: NamedRuleInstance[] = rules.map( 43 | ({ ruleDefinition, ruleConfig }) => { 44 | const { ruleName } = ruleDefinition; 45 | const report = (nodeViolation: NodeViolation) => 46 | violations.push({ ruleName, fileName, ...nodeViolation }); 47 | const context = { 48 | customTypeNames, 49 | enumNames, 50 | fileName, 51 | report, 52 | sourceCode, 53 | }; 54 | const ruleInstance = ruleDefinition.create(ruleConfig, context); 55 | return { ruleName, ruleInstance }; 56 | }, 57 | ); 58 | 59 | // Run each rule instance for each AST node. 60 | const models = listModelBlocks(prismaSchema); 61 | models.forEach((model) => { 62 | const comments = listIgnoreModelComments(model); 63 | if (isModelEntirelyIgnored(comments)) { 64 | return; 65 | } 66 | const fields = listFields(model); 67 | namedRuleInstances 68 | .filter(({ ruleName }) => !isRuleEntirelyIgnored(ruleName, comments)) 69 | .forEach(({ ruleInstance }) => { 70 | if ('Model' in ruleInstance) { 71 | ruleInstance.Model(model); 72 | } 73 | if ('Field' in ruleInstance) { 74 | fields.forEach((field) => { 75 | ruleInstance.Field(model, field); 76 | }); 77 | } 78 | }); 79 | }); 80 | 81 | enums.forEach((enumObj) => { 82 | const comments = listIgnoreEnumComments(enumObj); 83 | if (isEnumEntirelyIgnored(comments)) { 84 | return; 85 | } 86 | namedRuleInstances 87 | .filter(({ ruleName }) => !isRuleEntirelyIgnored(ruleName, comments)) 88 | .forEach(({ ruleInstance }) => { 89 | if ('Enum' in ruleInstance) { 90 | ruleInstance.Enum(enumObj); 91 | } 92 | }); 93 | }); 94 | 95 | return violations; 96 | } 97 | -------------------------------------------------------------------------------- /src/output/console.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import { getTruncatedFileName } from '#src/common/file.js'; 4 | import type { Violation } from '#src/common/violation.js'; 5 | import type { FileViolationList } from '#src/lint-prisma-files.js'; 6 | import type { OutputFormat } from '#src/output/output-format.js'; 7 | import { renderViolationsContextual } from '#src/output/render/render-contextual.js'; 8 | import { renderViolationsJsonObject } from '#src/output/render/render-json.js'; 9 | import { renderViolationsSimple } from '#src/output/render/render-simple.js'; 10 | 11 | /* eslint-disable no-console */ 12 | 13 | export function outputToConsole( 14 | fileViolationList: FileViolationList, 15 | outputFormat: OutputFormat, 16 | quiet: boolean, 17 | ) { 18 | switch (outputFormat) { 19 | case 'filepath': 20 | outputFilepath(fileViolationList, quiet); 21 | break; 22 | case 'simple': 23 | outputSimple(fileViolationList, quiet); 24 | break; 25 | case 'contextual': 26 | outputContextual(fileViolationList); 27 | break; 28 | case 'json': 29 | outputJson(fileViolationList); 30 | break; 31 | case 'none': 32 | break; 33 | default: 34 | throw new Error(`Unknown output format: ${outputFormat}`); 35 | } 36 | } 37 | 38 | function outputFilepath(fileViolationList: FileViolationList, quiet: boolean) { 39 | fileViolationList.forEach(({ fileName, violations }) => { 40 | maybeOutputPath(fileName, violations, quiet); 41 | }); 42 | } 43 | 44 | function outputJson(fileViolationList: FileViolationList) { 45 | const list = fileViolationList.flatMap(({ violations }) => 46 | renderViolationsJsonObject(violations), 47 | ); 48 | console.error(JSON.stringify({ violations: list })); 49 | } 50 | 51 | function outputSimple(fileViolationList: FileViolationList, quiet: boolean) { 52 | fileViolationList.forEach(({ fileName, violations }) => { 53 | const truncatedFileName = getTruncatedFileName(fileName); 54 | maybeOutputPath(truncatedFileName, violations, quiet); 55 | const lines = renderViolationsSimple(violations); 56 | if (lines.length !== 0) { 57 | console.error(lines.join('\n')); 58 | } 59 | }); 60 | } 61 | 62 | function outputContextual(fileViolationList: FileViolationList) { 63 | fileViolationList.forEach(({ sourceCode, violations }) => { 64 | const lines = renderViolationsContextual(sourceCode, violations); 65 | console.error(lines.join('\n')); 66 | }); 67 | } 68 | 69 | function maybeOutputPath( 70 | fileName: string, 71 | violations: Violation[], 72 | quiet: boolean, 73 | ) { 74 | const truncatedFileName = getTruncatedFileName(fileName); 75 | if (violations.length > 0) { 76 | console.error(`${truncatedFileName} ${chalk.red('✖')}`); 77 | return; 78 | } 79 | if (quiet) { 80 | return; 81 | } 82 | console.log(`${truncatedFileName} ${chalk.green('✔')}`); 83 | } 84 | -------------------------------------------------------------------------------- /src/output/output-format.ts: -------------------------------------------------------------------------------- 1 | export type OutputFormat = 2 | | 'contextual' 3 | | 'filepath' 4 | | 'json' 5 | | 'none' 6 | | 'simple'; 7 | -------------------------------------------------------------------------------- /src/output/render/__snapshots__/render-contextual.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render contextual matches snapshot 1`] = ` 4 | [ 5 | "", 6 | "test.prisma:1:1 Foo", 7 | "model Foo {", 8 | "^^^^^^^^^", 9 | " error Fake model rule violation message fake-model-rule-name", 10 | "", 11 | "test.prisma:2:3 Foo.displayName", 12 | " id Int @id", 13 | " ^^^^^^^^^^", 14 | " error Fake field rule violation message fake-field-rule-name", 15 | ] 16 | `; 17 | -------------------------------------------------------------------------------- /src/output/render/__snapshots__/render-json.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render json matches snapshot 1`] = ` 4 | [ 5 | { 6 | "fileName": "test.prisma", 7 | "location": { 8 | "endColumn": 9, 9 | "endLine": 1, 10 | "startColumn": 1, 11 | "startLine": 1, 12 | }, 13 | "message": "Fake model rule violation message", 14 | "ruleName": "fake-model-rule-name", 15 | }, 16 | { 17 | "fileName": "test.prisma", 18 | "location": { 19 | "endColumn": 12, 20 | "endLine": 2, 21 | "startColumn": 3, 22 | "startLine": 2, 23 | }, 24 | "message": "Fake field rule violation message", 25 | "ruleName": "fake-field-rule-name", 26 | }, 27 | ] 28 | `; 29 | -------------------------------------------------------------------------------- /src/output/render/__snapshots__/render-simple.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render json matches snapshot 1`] = ` 4 | [ 5 | " Foo 1:1", 6 | " error Fake model rule violation message fake-model-rule-name", 7 | " Foo.displayName 2:3", 8 | " error Fake field rule violation message fake-field-rule-name", 9 | ] 10 | `; 11 | -------------------------------------------------------------------------------- /src/output/render/render-contextual.test.ts: -------------------------------------------------------------------------------- 1 | import { renderViolationsContextual } from '#src/output/render/render-contextual.js'; 2 | import { 3 | MOCK_SOURCE_CODE, 4 | MOCK_VIOLATIONS, 5 | } from '#src/output/render/render-test-util.js'; 6 | 7 | describe('render contextual', () => { 8 | it('matches snapshot', () => { 9 | const result = renderViolationsContextual( 10 | MOCK_SOURCE_CODE, 11 | MOCK_VIOLATIONS, 12 | ); 13 | expect(result).toBeDefined(); 14 | expect(result).toMatchSnapshot(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/output/render/render-contextual.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import type { Violation } from '#src/common/violation.js'; 4 | import { keyViolationListPairs } from '#src/output/render/render-util.js'; 5 | 6 | export const renderViolationsContextual = ( 7 | sourceCode: string, 8 | violations: Violation[], 9 | ): string[] => { 10 | const pairs = keyViolationListPairs(violations); 11 | const lines = pairs.flatMap(([key, violations]) => { 12 | const first = violations[0]; 13 | const { fileName } = first; 14 | const rawLocation = 15 | first.field?.location ?? first.model?.location ?? first.enum?.location; 16 | if (!rawLocation) { 17 | throw new Error('No location'); 18 | } 19 | const { 20 | startLine, 21 | startColumn, 22 | endLine, 23 | endColumn, 24 | startOffset, 25 | endOffset, 26 | } = rawLocation; 27 | if ( 28 | !startLine || 29 | !startColumn || 30 | !endLine || 31 | !endColumn || 32 | !startOffset || 33 | !endOffset 34 | ) { 35 | throw new Error('No line or column'); 36 | } 37 | const lines = sourceCode.split('\n'); 38 | const containingLine = lines[startLine - 1]; 39 | const pointer = 40 | ' '.repeat(startColumn - 1) + '^'.repeat(endColumn - startColumn + 1); 41 | return [ 42 | '', 43 | `${fileName}:${startLine}:${startColumn} ${chalk.gray(`${key}`)}`, 44 | `${containingLine}`, 45 | `${pointer}`, 46 | ].concat( 47 | violations.flatMap((violation) => { 48 | const { ruleName, message } = violation; 49 | return [ 50 | ` ${chalk.red('error')} ${message} ${chalk.gray(`${ruleName}`)}`, 51 | ]; 52 | }), 53 | ); 54 | }); 55 | return lines; 56 | }; 57 | -------------------------------------------------------------------------------- /src/output/render/render-json.test.ts: -------------------------------------------------------------------------------- 1 | import { renderViolationsJsonObject } from '#src/output/render/render-json.js'; 2 | import { MOCK_VIOLATIONS } from '#src/output/render/render-test-util.js'; 3 | 4 | describe('render json', () => { 5 | it('matches snapshot', () => { 6 | const result = renderViolationsJsonObject(MOCK_VIOLATIONS); 7 | expect(result).toBeDefined(); 8 | expect(result).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/output/render/render-json.ts: -------------------------------------------------------------------------------- 1 | import type { Violation } from '#src/common/violation.js'; 2 | import { keyViolationListPairs } from '#src/output/render/render-util.js'; 3 | 4 | export const renderViolationsJson = (violations: Violation[]) => { 5 | const jsonObject = renderViolationsJsonObject(violations); 6 | return JSON.stringify(jsonObject); 7 | }; 8 | 9 | export const renderViolationsJsonObject = ( 10 | violations: Violation[], 11 | ): Record[] => { 12 | const pairs = keyViolationListPairs(violations); 13 | return pairs.flatMap(([_, violations]) => { 14 | const first = violations[0]; 15 | const { fileName } = first; 16 | const rawLocation = 17 | first.field?.location ?? first.model?.location ?? first.enum?.location; 18 | if (!rawLocation) { 19 | throw new Error('No location'); 20 | } 21 | const { startLine, startColumn, endLine, endColumn } = rawLocation; 22 | if ( 23 | startLine === undefined || 24 | startColumn === undefined || 25 | endLine === undefined || 26 | endColumn === undefined 27 | ) { 28 | throw new Error('No location'); 29 | } 30 | const location = { 31 | startLine, 32 | startColumn, 33 | endLine, 34 | endColumn, 35 | }; 36 | return violations.map((v) => ({ 37 | ruleName: v.ruleName, 38 | message: v.message, 39 | fileName, 40 | location, 41 | })); 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/output/render/render-simple.test.ts: -------------------------------------------------------------------------------- 1 | import { renderViolationsSimple } from '#src/output/render/render-simple.js'; 2 | import { MOCK_VIOLATIONS } from '#src/output/render/render-test-util.js'; 3 | 4 | describe('render json', () => { 5 | it('matches snapshot', () => { 6 | const result = renderViolationsSimple(MOCK_VIOLATIONS); 7 | expect(result).toBeDefined(); 8 | expect(result).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/output/render/render-simple.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import type { Violation } from '#src/common/violation.js'; 4 | import { keyViolationListPairs } from '#src/output/render/render-util.js'; 5 | 6 | export const renderViolationsSimple = (violations: Violation[]): string[] => { 7 | const pairs = keyViolationListPairs(violations); 8 | return pairs.flatMap(([key, violations]) => { 9 | const first = violations[0]; 10 | const location = 11 | first.field?.location ?? first.model?.location ?? first.enum?.location; 12 | if (!location) { 13 | throw new Error('No location'); 14 | } 15 | const { startLine, startColumn } = location; 16 | return [ 17 | ` ${key} ${chalk.gray(`${startLine}:${startColumn}`)}`, 18 | ...violations.flatMap(renderViolationSimple), 19 | ]; 20 | }); 21 | }; 22 | 23 | const renderViolationSimple = ({ ruleName, message }: Violation) => [ 24 | ` ${chalk.red('error')} ${message} ${chalk.gray(`${ruleName}`)}`, 25 | ]; 26 | -------------------------------------------------------------------------------- /src/output/render/render-test-util.ts: -------------------------------------------------------------------------------- 1 | import type { Model } from '@mrleebo/prisma-ast'; 2 | 3 | import type { Violation } from '#src/common/violation.js'; 4 | 5 | export const MOCK_SOURCE_CODE = `model Foo { 6 | id Int @id 7 | displayName String 8 | }`; 9 | 10 | const model: Model = { 11 | type: 'model', 12 | name: 'Foo', 13 | properties: [], 14 | location: { 15 | startLine: 1, 16 | startColumn: 1, 17 | startOffset: 1, 18 | endLine: 1, 19 | endColumn: 9, 20 | endOffset: 1, 21 | }, 22 | }; 23 | const fileName = 'test.prisma'; 24 | export const MOCK_VIOLATIONS: Violation[] = [ 25 | { 26 | ruleName: 'fake-model-rule-name', 27 | message: 'Fake model rule violation message', 28 | fileName, 29 | model, 30 | }, 31 | { 32 | ruleName: 'fake-field-rule-name', 33 | message: 'Fake field rule violation message', 34 | fileName, 35 | model, 36 | field: { 37 | type: 'field', 38 | name: 'displayName', 39 | fieldType: 'String', 40 | location: { 41 | startLine: 2, 42 | startColumn: 3, 43 | startOffset: 1, 44 | endLine: 2, 45 | endColumn: 12, 46 | endOffset: 1, 47 | }, 48 | }, 49 | }, 50 | ]; 51 | -------------------------------------------------------------------------------- /src/output/render/render-util.ts: -------------------------------------------------------------------------------- 1 | import type { Violation } from '#src/common/violation.js'; 2 | 3 | export const keyViolationListPairs = ( 4 | violations: Violation[], 5 | ): [string, Violation[]][] => { 6 | const groupedByKey = violations.reduce( 7 | (acc, violation) => { 8 | const { model, field, enum: enumObj } = violation; 9 | const key = field 10 | ? `${model.name}.${field.name}` 11 | : enumObj 12 | ? enumObj.name 13 | : model.name; 14 | const violations = acc[key] ?? []; 15 | return { ...acc, [key]: [...violations, violation] }; 16 | }, 17 | {} as Record, 18 | ); 19 | return Object.entries(groupedByKey).sort(); 20 | }; 21 | -------------------------------------------------------------------------------- /src/rule-definitions.ts: -------------------------------------------------------------------------------- 1 | import type { RuleDefinition } from '#src/common/rule.js'; 2 | import banUnboundedStringType from '#src/rules/ban-unbounded-string-type.js'; 3 | import enumNamePascalCase from '#src/rules/enum-name-pascal-case.js'; 4 | import enumValueSnakeCase from '#src/rules/enum-value-snake-case.js'; 5 | import fieldNameCamelCase from '#src/rules/field-name-camel-case.js'; 6 | import fieldNameGrammaticalNumber from '#src/rules/field-name-grammatical-number.js'; 7 | import fieldNameMappingSnakeCase from '#src/rules/field-name-mapping-snake-case.js'; 8 | import fieldOrder from '#src/rules/field-order.js'; 9 | import forbidField from '#src/rules/forbid-field.js'; 10 | import forbidRequiredIgnoredField from '#src/rules/forbid-required-ignored-field.js'; 11 | import modelNameGrammaticalNumber from '#src/rules/model-name-grammatical-number.js'; 12 | import modelNameMappingSnakeCase from '#src/rules/model-name-mapping-snake-case.js'; 13 | import modelNamePascalCase from '#src/rules/model-name-pascal-case.js'; 14 | import modelNamePrefix from '#src/rules/model-name-prefix.js'; 15 | import requireDefaultEmptyArrays from '#src/rules/require-default-empty-arrays.js'; 16 | import requireFieldIndex from '#src/rules/require-field-index.js'; 17 | import requireFieldType from '#src/rules/require-field-type.js'; 18 | import requireField from '#src/rules/require-field.js'; 19 | 20 | export default [ 21 | banUnboundedStringType, 22 | enumNamePascalCase, 23 | enumValueSnakeCase, 24 | fieldNameCamelCase, 25 | fieldNameGrammaticalNumber, 26 | fieldNameMappingSnakeCase, 27 | fieldOrder, 28 | forbidField, 29 | forbidRequiredIgnoredField, 30 | modelNameGrammaticalNumber, 31 | modelNameMappingSnakeCase, 32 | modelNamePascalCase, 33 | modelNamePrefix, 34 | requireDefaultEmptyArrays, 35 | requireField, 36 | requireFieldIndex, 37 | requireFieldType, 38 | ] satisfies RuleDefinition[]; 39 | -------------------------------------------------------------------------------- /src/rules/ban-unbounded-string-type.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import banUnboundedStringType from '#src/rules/ban-unbounded-string-type.js'; 4 | 5 | describe('ban-unbounded-string-type', () => { 6 | const getRunner = (config?: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'ban-unbounded-string-type': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [banUnboundedStringType], 16 | }); 17 | 18 | describe('empty config', () => { 19 | const run = getRunner(); 20 | 21 | describe('bounded string type', () => { 22 | it('returns no violations', async () => { 23 | const violations = await run(` 24 | model User { 25 | id String @db.VarChar(36) 26 | } 27 | `); 28 | expect(violations.length).toEqual(0); 29 | }); 30 | }); 31 | 32 | describe('unbounded string type', () => { 33 | it('returns violation', async () => { 34 | const violations = await run(` 35 | model Users { 36 | id String 37 | } 38 | `); 39 | expect(violations.length).toEqual(1); 40 | }); 41 | }); 42 | 43 | describe('native @db.Text type without override', () => { 44 | it('returns violation', async () => { 45 | const violations = await run(` 46 | model Users { 47 | id String @db.Text 48 | } 49 | `); 50 | expect(violations.length).toEqual(1); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('allow native @db.Text type', () => { 56 | const run = getRunner({ 57 | allowNativeTextType: true, 58 | }); 59 | 60 | describe('@db.Text type permitted', () => { 61 | it('returns no violations', async () => { 62 | const violations = await run(` 63 | model User { 64 | id String @db.Text 65 | } 66 | `); 67 | expect(violations.length).toEqual(0); 68 | }); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/rules/ban-unbounded-string-type.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 4 | 5 | const RULE_NAME = 'ban-unbounded-string-type'; 6 | 7 | const Config = z 8 | .object({ 9 | allowNativeTextType: z.boolean().optional(), 10 | }) 11 | .strict(); 12 | 13 | /** 14 | * Checks that String fields are defined with a database native type to 15 | * limit the length, e.g. `@db.VarChar(x)`. 16 | * Motivation inspired by https://brandur.org/text - to avoid unintentionally 17 | * building public APIs that support unlimited-length strings. 18 | * 19 | * @example 20 | * // good 21 | * model User { 22 | * id String @db.VarChar(36) 23 | * } 24 | * 25 | * // bad 26 | * model User { 27 | * id String 28 | * } 29 | * 30 | * // bad 31 | * model User { 32 | * id String @db.Text 33 | * } 34 | * 35 | * @example { allowNativeTextType: true } 36 | * // good 37 | * model User { 38 | * id String @db.Text 39 | * } 40 | * 41 | */ 42 | export default { 43 | ruleName: RULE_NAME, 44 | configSchema: Config, 45 | create: (config, context) => { 46 | const { allowNativeTextType } = config; 47 | return { 48 | Field: (model, field) => { 49 | if (field.fieldType !== 'String') { 50 | return; 51 | } 52 | 53 | const nativeTypeAttribute = field.attributes?.find( 54 | (attr) => attr.group === 'db', 55 | ); 56 | 57 | if ( 58 | !nativeTypeAttribute || 59 | (nativeTypeAttribute.name === 'Text' && !allowNativeTextType) 60 | ) { 61 | const message = 62 | 'String fields must have a native type attribute to ensure maximum length, e.g. `@db.VarChar(x)`.'; 63 | context.report({ model, field, message }); 64 | } 65 | }, 66 | }; 67 | }, 68 | } satisfies FieldRuleDefinition>; 69 | -------------------------------------------------------------------------------- /src/rules/enum-name-pascal-case.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import enumNamePascalCase from '#src/rules/enum-name-pascal-case.js'; 4 | 5 | describe('enum-name-pascal-case', () => { 6 | const getRunner = (config?: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'enum-name-pascal-case': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [enumNamePascalCase], 16 | }); 17 | 18 | describe('ignore comments', () => { 19 | const run = getRunner(); 20 | 21 | it('respects rule-specific ignore comments', async () => { 22 | const violations = await run(` 23 | enum exampleOptions { 24 | /// prisma-lint-ignore-enum enum-name-pascal-case 25 | value1 26 | } 27 | `); 28 | expect(violations.length).toEqual(0); 29 | }); 30 | 31 | it('respects model-wide ignore comments', async () => { 32 | const violations = await run(` 33 | enum exampleOptions { 34 | /// prisma-lint-ignore-enum 35 | value1 36 | } 37 | `); 38 | expect(violations.length).toEqual(0); 39 | }); 40 | }); 41 | 42 | describe('without config', () => { 43 | const run = getRunner(); 44 | 45 | describe('single word', () => { 46 | it('returns no violations', async () => { 47 | const violations = await run(` 48 | enum Example { 49 | value1 50 | } 51 | `); 52 | expect(violations.length).toEqual(0); 53 | }); 54 | }); 55 | 56 | describe('compound word in PascalCase', () => { 57 | it('returns no violations', async () => { 58 | const violations = await run(` 59 | enum ExampleOptions { 60 | value1 61 | } 62 | `); 63 | expect(violations.length).toEqual(0); 64 | }); 65 | }); 66 | 67 | describe('first character is not uppercase', () => { 68 | it('returns violation', async () => { 69 | const violations = await run(` 70 | enum exampleOptions { 71 | value1 72 | } 73 | `); 74 | expect(violations.length).toEqual(1); 75 | }); 76 | }); 77 | 78 | describe('contains underscore', () => { 79 | it('returns violation', async () => { 80 | const violations = await run(` 81 | enum example_options { 82 | value1 83 | } 84 | `); 85 | expect(violations.length).toEqual(1); 86 | }); 87 | }); 88 | 89 | describe('permits digits anywhere', () => { 90 | it('returns no violations', async () => { 91 | const violations = await run(` 92 | enum Exampl3O0ptions { 93 | value1 94 | } 95 | `); 96 | expect(violations.length).toEqual(0); 97 | }); 98 | }); 99 | }); 100 | 101 | describe('with allowlist string', () => { 102 | const run = getRunner({ allowList: ['exampleOptions'] }); 103 | 104 | it('returns no violations', async () => { 105 | const violations = await run(` 106 | enum exampleOptions { 107 | value1 108 | } 109 | `); 110 | expect(violations.length).toEqual(0); 111 | }); 112 | 113 | it('allowList string requires full match', async () => { 114 | const violations = await run(` 115 | enum exampleOptionsWithSuffix { 116 | value1 117 | } 118 | `); 119 | expect(violations.length).toEqual(1); 120 | }); 121 | }); 122 | 123 | describe('with allowlist regexp', () => { 124 | const run = getRunner({ allowList: [/^db.*/] }); 125 | 126 | it('returns no violations', async () => { 127 | const violations = await run(` 128 | enum db_exampleOptions { 129 | value1 130 | } 131 | `); 132 | expect(violations.length).toEqual(0); 133 | }); 134 | }); 135 | 136 | describe('with trimPrefix single string', () => { 137 | const run = getRunner({ trimPrefix: 'db' }); 138 | 139 | it('returns no violations', async () => { 140 | const violations = await run(` 141 | enum dbExampleOptions { 142 | value1 143 | } 144 | `); 145 | expect(violations.length).toEqual(0); 146 | }); 147 | 148 | it('remaining suffix must be PascalCase', async () => { 149 | const violations = await run(` 150 | enum dbcamelCase { 151 | value1 152 | } 153 | `); 154 | expect(violations.length).toEqual(1); 155 | }); 156 | }); 157 | 158 | describe('with trimPrefix single regexp', () => { 159 | const run = getRunner({ trimPrefix: /^db/ }); 160 | 161 | it('returns no violations', async () => { 162 | const violations = await run(` 163 | enum dbExampleOptions { 164 | value1 165 | } 166 | `); 167 | expect(violations.length).toEqual(0); 168 | }); 169 | 170 | it('remaining suffix must be PascalCase', async () => { 171 | const violations = await run(` 172 | enum dbcamelCase { 173 | value1 174 | } 175 | `); 176 | expect(violations.length).toEqual(1); 177 | }); 178 | }); 179 | 180 | describe('with trimPrefix array', () => { 181 | const run = getRunner({ trimPrefix: ['db', /^eg/] }); 182 | 183 | it('returns no violations for first prefix', async () => { 184 | const violations = await run(` 185 | enum dbExampleOptions { 186 | value1 187 | } 188 | `); 189 | expect(violations.length).toEqual(0); 190 | }); 191 | 192 | it('returns no violations for second prefix', async () => { 193 | const violations = await run(` 194 | enum egExampleOptions { 195 | value1 196 | } 197 | `); 198 | expect(violations.length).toEqual(0); 199 | }); 200 | 201 | it('remaining suffix after string must be PascalCase', async () => { 202 | const violations = await run(` 203 | enum dbexampleOptions { 204 | value1 205 | } 206 | `); 207 | expect(violations.length).toEqual(1); 208 | }); 209 | 210 | it('remaining suffix after regexp must be PascalCase', async () => { 211 | const violations = await run(` 212 | enum egexampleOptions { 213 | value1 214 | } 215 | `); 216 | expect(violations.length).toEqual(1); 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/rules/enum-name-pascal-case.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { 4 | matchesAllowList, 5 | trimPrefix, 6 | } from '#src/common/rule-config-helpers.js'; 7 | import type { EnumRuleDefinition } from '#src/common/rule.js'; 8 | 9 | const RULE_NAME = 'enum-name-pascal-case'; 10 | 11 | const Config = z 12 | .object({ 13 | allowList: z.array(z.union([z.string(), z.instanceof(RegExp)])).optional(), 14 | trimPrefix: z 15 | .union([ 16 | z.string(), 17 | z.instanceof(RegExp), 18 | z.array(z.union([z.string(), z.instanceof(RegExp)])), 19 | ]) 20 | .optional(), 21 | }) 22 | .strict(); 23 | 24 | /** 25 | * Checks that enum names are in PascalCase. 26 | * 27 | * @example 28 | * // good 29 | * enum ExampleOptions { 30 | * value1 31 | * } 32 | * 33 | * // bad 34 | * enum exampleOptions { 35 | * value1 36 | * } 37 | * 38 | * // bad 39 | * enum example_options { 40 | * value1 41 | * } 42 | * 43 | */ 44 | export default { 45 | ruleName: RULE_NAME, 46 | configSchema: Config, 47 | create: (config, context) => { 48 | const { allowList, trimPrefix: trimPrefixConfig } = config; 49 | return { 50 | Enum: (enumObj) => { 51 | if (matchesAllowList(enumObj.name, allowList)) { 52 | return; 53 | } 54 | const nameWithoutPrefix = trimPrefix(enumObj.name, trimPrefixConfig); 55 | if (!nameWithoutPrefix.match(/^[A-Z][a-zA-Z0-9]*$/)) { 56 | const message = 'Enum name should be in PascalCase.'; 57 | context.report({ enum: enumObj, message }); 58 | } 59 | }, 60 | }; 61 | }, 62 | } satisfies EnumRuleDefinition>; 63 | -------------------------------------------------------------------------------- /src/rules/enum-value-snake-case.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { getRuleIgnoreParams } from '#src/common/ignore.js'; 4 | import { 5 | matchesAllowList, 6 | trimPrefix, 7 | } from '#src/common/rule-config-helpers.js'; 8 | import type { EnumRuleDefinition } from '#src/common/rule.js'; 9 | import { toSnakeCase } from '#src/common/snake-case.js'; 10 | 11 | const RULE_NAME = 'enum-value-snake-case'; 12 | 13 | const Config = z 14 | .object({ 15 | case: z.enum(['lower', 'upper']).optional(), 16 | compoundWords: z.array(z.string()).optional(), 17 | allowList: z.array(z.union([z.string(), z.instanceof(RegExp)])).optional(), 18 | trimPrefix: z 19 | .union([ 20 | z.string(), 21 | z.instanceof(RegExp), 22 | z.array(z.union([z.string(), z.instanceof(RegExp)])), 23 | ]) 24 | .optional(), 25 | }) 26 | .strict(); 27 | 28 | /** 29 | * Checks that enum values are in either upper or lower snake case. 30 | * 31 | * Defaults to lower snake case. Use the `case` option to enforce upper snake case. 32 | * 33 | * This rule supports selectively ignoring enum values via the 34 | * `prisma-lint-ignore-enum` comment, like so: 35 | * 36 | * /// prisma-lint-ignore-enum enum-value-snake-case NotSnakeCase 37 | * 38 | * That will permit an enum value of `NotSnakeCase`. Other 39 | * values for the enum must still be in snake_case. A comma-separated list of values 40 | * can be provided to ignore multiple enum values. 41 | * 42 | * @example 43 | * // good 44 | * enum Example { 45 | * value 46 | * } 47 | * 48 | * // good 49 | * enum Example { 50 | * value_1 51 | * } 52 | * 53 | * // bad 54 | * enum Example { 55 | * Value 56 | * } 57 | * 58 | * // bad 59 | * enum Example { 60 | * VALUE 61 | * } 62 | * 63 | * // bad 64 | * enum Example { 65 | * camelCase 66 | * } 67 | * 68 | * @example { case: ["upper"] } 69 | * // good 70 | * enum Example { 71 | * VALUE 72 | * } 73 | * 74 | * // good 75 | * enum Example { 76 | * VALUE_1 77 | * } 78 | * 79 | * // bad 80 | * enum Example { 81 | * Value 82 | * } 83 | * 84 | * // bad 85 | * enum Example { 86 | * value 87 | * } 88 | * 89 | * // bad 90 | * enum Example { 91 | * camelCase 92 | * } 93 | */ 94 | export default { 95 | ruleName: RULE_NAME, 96 | configSchema: Config, 97 | create: (config, context) => { 98 | const { 99 | allowList, 100 | trimPrefix: trimPrefixConfig, 101 | compoundWords, 102 | case: caseConfig, 103 | } = config; 104 | return { 105 | Enum: (enumObj) => { 106 | enumObj.enumerators 107 | .filter((enumerator) => enumerator.type === 'enumerator') 108 | .forEach((enumValue) => { 109 | if ( 110 | getRuleIgnoreParams(enumObj, RULE_NAME).includes(enumValue.name) 111 | ) { 112 | return; 113 | } 114 | if (matchesAllowList(enumValue.name, allowList)) { 115 | return; 116 | } 117 | const valueWithoutPrefix = trimPrefix( 118 | enumValue.name, 119 | trimPrefixConfig, 120 | ); 121 | const expectedValue = toSnakeCase(valueWithoutPrefix, { 122 | compoundWords, 123 | case: caseConfig, 124 | }); 125 | if (valueWithoutPrefix !== expectedValue) { 126 | const message = `Enum value should be in snake_case: '${valueWithoutPrefix}' (expected '${expectedValue}').`; 127 | context.report({ enum: enumObj, message }); 128 | } 129 | }); 130 | }, 131 | }; 132 | }, 133 | } satisfies EnumRuleDefinition>; 134 | -------------------------------------------------------------------------------- /src/rules/field-name-camel-case.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import fieldNameCamelCase from '#src/rules/field-name-camel-case.js'; 4 | 5 | describe('field-name-camel-case', () => { 6 | const getRunner = (config?: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'field-name-camel-case': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [fieldNameCamelCase], 16 | }); 17 | 18 | describe('ignore comments', () => { 19 | const run = getRunner(); 20 | 21 | it('respects rule-specific ignore comments', async () => { 22 | const violations = await run(` 23 | model users { 24 | /// prisma-lint-ignore-model field-name-camel-case 25 | Id String @id 26 | } 27 | `); 28 | expect(violations.length).toEqual(0); 29 | }); 30 | 31 | it('respects model-wide ignore comments', async () => { 32 | const violations = await run(` 33 | model users { 34 | /// prisma-lint-ignore-model 35 | id String @id 36 | } 37 | `); 38 | expect(violations.length).toEqual(0); 39 | }); 40 | }); 41 | 42 | describe('without config', () => { 43 | const run = getRunner(); 44 | 45 | describe('single word', () => { 46 | it('returns no violations', async () => { 47 | const violations = await run(` 48 | model User { 49 | id String @id 50 | } 51 | `); 52 | expect(violations.length).toEqual(0); 53 | }); 54 | }); 55 | 56 | describe('compound word in camelCase', () => { 57 | it('returns no violations', async () => { 58 | const violations = await run(` 59 | model Users { 60 | rowId String @id 61 | } 62 | `); 63 | expect(violations.length).toEqual(0); 64 | }); 65 | }); 66 | 67 | describe('first character is not lowercase', () => { 68 | it('returns violation', async () => { 69 | const violations = await run(` 70 | model Users { 71 | RowID String @id 72 | } 73 | `); 74 | expect(violations.length).toEqual(1); 75 | }); 76 | }); 77 | 78 | describe('contains underscore', () => { 79 | it('returns violation', async () => { 80 | const violations = await run(` 81 | model Users { 82 | row_id String @id 83 | } 84 | `); 85 | expect(violations.length).toEqual(1); 86 | }); 87 | }); 88 | 89 | describe('permits digits anywhere', () => { 90 | it('returns no violations', async () => { 91 | const violations = await run(` 92 | model Users { 93 | row2Id4 String @id 94 | } 95 | `); 96 | expect(violations.length).toEqual(0); 97 | }); 98 | }); 99 | }); 100 | 101 | describe('with allowlist string', () => { 102 | const run = getRunner({ allowList: ['RowId'] }); 103 | 104 | it('returns no violations', async () => { 105 | const violations = await run(` 106 | model User { 107 | RowId String @id 108 | } 109 | `); 110 | expect(violations.length).toEqual(0); 111 | }); 112 | 113 | it('allowList string requires full match', async () => { 114 | const violations = await run(` 115 | model User { 116 | RowIdPrimary String @id 117 | } 118 | `); 119 | expect(violations.length).toEqual(1); 120 | }); 121 | }); 122 | 123 | describe('with allowlist regexp', () => { 124 | const run = getRunner({ allowList: [/^db.*/] }); 125 | 126 | it('returns no violations', async () => { 127 | const violations = await run(` 128 | model User { 129 | db_RowId String @id 130 | } 131 | `); 132 | expect(violations.length).toEqual(0); 133 | }); 134 | }); 135 | 136 | describe('with trimPrefix single string', () => { 137 | const run = getRunner({ trimPrefix: 'db_' }); 138 | 139 | it('returns no violations', async () => { 140 | const violations = await run(` 141 | model User { 142 | db_rowId String @id 143 | } 144 | `); 145 | expect(violations.length).toEqual(0); 146 | }); 147 | 148 | it('remaining suffix must be camelCase', async () => { 149 | const violations = await run(` 150 | model User { 151 | db_RowId String @id 152 | } 153 | `); 154 | expect(violations.length).toEqual(1); 155 | }); 156 | }); 157 | 158 | describe('with trimPrefix single regexp', () => { 159 | const run = getRunner({ trimPrefix: /^db_/ }); 160 | 161 | it('returns no violations', async () => { 162 | const violations = await run(` 163 | model User { 164 | db_rowId String @id 165 | } 166 | `); 167 | expect(violations.length).toEqual(0); 168 | }); 169 | 170 | it('remaining suffix must be camelCase', async () => { 171 | const violations = await run(` 172 | model User { 173 | db_RowId String @id 174 | } 175 | `); 176 | expect(violations.length).toEqual(1); 177 | }); 178 | }); 179 | 180 | describe('with trimPrefix array', () => { 181 | const run = getRunner({ trimPrefix: ['db_', /^eg_/] }); 182 | 183 | it('returns no violations for first prefix', async () => { 184 | const violations = await run(` 185 | model User { 186 | db_rowId String @id 187 | } 188 | `); 189 | expect(violations.length).toEqual(0); 190 | }); 191 | 192 | it('returns no violations for second prefix', async () => { 193 | const violations = await run(` 194 | model User { 195 | eg_rowId String @id 196 | } 197 | `); 198 | expect(violations.length).toEqual(0); 199 | }); 200 | 201 | it('remaining suffix after string must be camelCase', async () => { 202 | const violations = await run(` 203 | model User { 204 | db_RowId String @id 205 | } 206 | `); 207 | expect(violations.length).toEqual(1); 208 | }); 209 | 210 | it('remaining suffix after regexp must be camelCase', async () => { 211 | const violations = await run(` 212 | model User { 213 | eg_RowId String @id 214 | } 215 | `); 216 | expect(violations.length).toEqual(1); 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/rules/field-name-camel-case.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { 4 | matchesAllowList, 5 | trimPrefix, 6 | } from '#src/common/rule-config-helpers.js'; 7 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 8 | 9 | const RULE_NAME = 'field-name-camel-case'; 10 | 11 | const Config = z 12 | .object({ 13 | allowList: z.array(z.union([z.string(), z.instanceof(RegExp)])).optional(), 14 | trimPrefix: z 15 | .union([ 16 | z.string(), 17 | z.instanceof(RegExp), 18 | z.array(z.union([z.string(), z.instanceof(RegExp)])), 19 | ]) 20 | .optional(), 21 | }) 22 | .strict(); 23 | 24 | /** 25 | * Checks that field names are in camelCase. 26 | * 27 | * @example 28 | * // good 29 | * model User { 30 | * rowId String @id 31 | * } 32 | * 33 | * // bad 34 | * model User { 35 | * RowId String @id 36 | * } 37 | * 38 | * // bad 39 | * model User { 40 | * row_id String @id 41 | * } 42 | * 43 | */ 44 | export default { 45 | ruleName: RULE_NAME, 46 | configSchema: Config, 47 | create: (config, context) => { 48 | const { allowList, trimPrefix: trimPrefixConfig } = config; 49 | return { 50 | Field: (model, field) => { 51 | if (matchesAllowList(field.name, allowList)) { 52 | return; 53 | } 54 | const nameWithoutPrefix = trimPrefix(field.name, trimPrefixConfig); 55 | if (!nameWithoutPrefix.match(/^[a-z][a-zA-Z0-9]*$/)) { 56 | const message = 'Field name should be in camelCase.'; 57 | context.report({ model, field, message }); 58 | } 59 | }, 60 | }; 61 | }, 62 | } satisfies FieldRuleDefinition>; 63 | -------------------------------------------------------------------------------- /src/rules/field-name-grammatical-number.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import listFieldNameGrammaticalNumber from '#src/rules/field-name-grammatical-number.js'; 4 | 5 | describe('field-name-grammatical-number', () => { 6 | const getRunner = (config: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'field-name-grammatical-number': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [listFieldNameGrammaticalNumber], 16 | }); 17 | 18 | describe('expecting singular', () => { 19 | const run = getRunner({ ifList: 'singular' }); 20 | 21 | describe('singular', () => { 22 | it('returns no violations for singular list field', async () => { 23 | const violations = await run(` 24 | model User { 25 | email String[] 26 | } 27 | `); 28 | expect(violations.length).toEqual(0); 29 | }); 30 | 31 | it('ignores non-list fields', async () => { 32 | const violations = await run(` 33 | model User { 34 | emails String 35 | } 36 | `); 37 | expect(violations.length).toEqual(0); 38 | }); 39 | }); 40 | 41 | describe('plural', () => { 42 | it('returns violation for plural list field', async () => { 43 | const violations = await run(` 44 | model User { 45 | emails String[] 46 | } 47 | `); 48 | expect(violations.length).toEqual(1); 49 | expect(violations[0].message).toEqual( 50 | 'Expected singular name for list field.', 51 | ); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('expecting plural', () => { 57 | const run = getRunner({ ifList: 'plural' }); 58 | 59 | describe('singular', () => { 60 | it('returns violation for singular list field', async () => { 61 | const violations = await run(` 62 | model User { 63 | email String[] 64 | } 65 | `); 66 | expect(violations.length).toEqual(1); 67 | expect(violations[0].message).toEqual( 68 | 'Expected plural name for list field.', 69 | ); 70 | }); 71 | }); 72 | 73 | describe('plural', () => { 74 | it('returns no violations for plural list field', async () => { 75 | const violations = await run(` 76 | model User { 77 | emails String[] 78 | } 79 | `); 80 | expect(violations.length).toEqual(0); 81 | }); 82 | 83 | it('ignores non-list fields', async () => { 84 | const violations = await run(` 85 | model User { 86 | email String 87 | } 88 | `); 89 | expect(violations.length).toEqual(0); 90 | }); 91 | }); 92 | }); 93 | 94 | describe('ifListAllow', () => { 95 | describe('without ifListAllow', () => { 96 | const run = getRunner({ ifList: 'singular' }); 97 | 98 | it('returns violation for plural list field', async () => { 99 | const violations = await run(` 100 | model User { 101 | data String[] 102 | } 103 | `); 104 | expect(violations.length).toEqual(1); 105 | }); 106 | }); 107 | 108 | describe('with string ifListAllow', () => { 109 | const run = getRunner({ ifList: 'singular', ifListAllow: ['data'] }); 110 | 111 | it('returns no violations for allowlisted field', async () => { 112 | const violations = await run(` 113 | model User { 114 | data String[] 115 | } 116 | `); 117 | expect(violations.length).toEqual(0); 118 | }); 119 | 120 | it('still checks non-allowlisted fields', async () => { 121 | const violations = await run(` 122 | model User { 123 | data String[] 124 | emails String[] 125 | } 126 | `); 127 | expect(violations.length).toEqual(1); 128 | expect(violations[0].message).toEqual( 129 | 'Expected singular name for list field.', 130 | ); 131 | }); 132 | }); 133 | 134 | describe('with regex ifListAllow', () => { 135 | const run = getRunner({ ifList: 'singular', ifListAllow: [/data/] }); 136 | 137 | it('returns no violations for allowlisted field', async () => { 138 | const violations = await run(` 139 | model User { 140 | data String[] 141 | } 142 | `); 143 | expect(violations.length).toEqual(0); 144 | }); 145 | 146 | it('returns violation for non-allowlisted field', async () => { 147 | const violations = await run(` 148 | model User { 149 | emails String[] 150 | } 151 | `); 152 | expect(violations.length).toEqual(1); 153 | }); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/rules/field-name-grammatical-number.ts: -------------------------------------------------------------------------------- 1 | import type { Field, Model } from '@mrleebo/prisma-ast'; 2 | 3 | import pluralize from 'pluralize'; 4 | import { z } from 'zod'; 5 | 6 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 7 | 8 | const RULE_NAME = 'field-name-grammatical-number'; 9 | 10 | const Config = z 11 | .object({ 12 | ifList: z.enum(['singular', 'plural']), 13 | ifListAllow: z 14 | .array(z.union([z.string(), z.instanceof(RegExp)])) 15 | .optional(), 16 | }) 17 | .strict(); 18 | 19 | /** 20 | * Checks that each list field name matches the plural or singular enforced style. 21 | * Only applies to fields that are arrays (list types). 22 | * 23 | * @example { ifList: "singular" } 24 | * // good 25 | * model User { 26 | * email String[] 27 | * } 28 | * 29 | * // bad 30 | * model User { 31 | * emails String[] 32 | * } 33 | * 34 | * @example { ifList: "plural" } 35 | * // good 36 | * model User { 37 | * emails String[] 38 | * } 39 | * 40 | * // bad 41 | * model User { 42 | * email String[] 43 | * } 44 | * 45 | * @example { ifList: "singular", ifListAllow: ["data"] } 46 | * // good 47 | * model User { 48 | * data String[] 49 | * email String[] 50 | * } 51 | * 52 | * // bad 53 | * model User { 54 | * emails String[] 55 | * } 56 | */ 57 | export default { 58 | ruleName: RULE_NAME, 59 | configSchema: Config, 60 | create: (config, context) => { 61 | const { ifListAllow, ifList } = config; 62 | const allowlist = ifListAllow ?? []; 63 | return { 64 | Field: (model: Model, field: Field) => { 65 | if (!field.array) { 66 | return; 67 | } 68 | 69 | const fieldName = field.name; 70 | if ( 71 | allowlist.includes(fieldName) || 72 | allowlist.some( 73 | (item) => item instanceof RegExp && item.test(fieldName), 74 | ) 75 | ) { 76 | return; 77 | } 78 | 79 | const isPlural = pluralize.isPlural(fieldName); 80 | if (isPlural && ifList === 'singular') { 81 | context.report({ 82 | model, 83 | field, 84 | message: 'Expected singular name for list field.', 85 | }); 86 | } 87 | if (!isPlural && ifList === 'plural') { 88 | context.report({ 89 | model, 90 | field, 91 | message: 'Expected plural name for list field.', 92 | }); 93 | } 94 | }, 95 | }; 96 | }, 97 | } satisfies FieldRuleDefinition>; 98 | -------------------------------------------------------------------------------- /src/rules/field-name-mapping-snake-case.ts: -------------------------------------------------------------------------------- 1 | import type { Attribute } from '@mrleebo/prisma-ast'; 2 | 3 | import { z } from 'zod'; 4 | 5 | import { getRuleIgnoreParams } from '#src/common/ignore.js'; 6 | import { 7 | getMappedName, 8 | looksLikeAssociationFieldType, 9 | } from '#src/common/prisma.js'; 10 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 11 | import { toSnakeCase } from '#src/common/snake-case.js'; 12 | 13 | const RULE_NAME = 'field-name-mapping-snake-case'; 14 | 15 | const Config = z 16 | .object({ 17 | compoundWords: z.array(z.string()).optional(), 18 | requireUnderscorePrefixForIds: z.boolean().optional(), 19 | }) 20 | .strict() 21 | .optional(); 22 | 23 | /** 24 | * Checks that the mapped name of a field is the expected snake case. 25 | * 26 | * This rule support selectively ignoring fields with parameterized comments. 27 | * 28 | * That will ignore only `tenantId` field violations for the model. Other 29 | * fields will still be enforced. A comma-separated list of fields can be 30 | * provided to ignore multiple fields. 31 | * 32 | * @example 33 | * // good 34 | * model UserRole { 35 | * userId String @map(name: "user_id") 36 | * } 37 | * 38 | * model UserRole { 39 | * // No mapping needed for single-word field name. 40 | * id String 41 | * // No mapping needed for association field. 42 | * grantedByUser User 43 | * } 44 | * 45 | * // bad 46 | * model UserRole { 47 | * userId String 48 | * } 49 | * 50 | * model UserRole { 51 | * userId String @map(name: "user_i_d") 52 | * } 53 | * 54 | * @example { compoundWords: ["GraphQL"] } 55 | * // good 56 | * model PersistedQuery { 57 | * graphQLId String @map(name: "graphql_id") 58 | * } 59 | * 60 | * // bad 61 | * model PersistedQuery { 62 | * graphQLId String @map(name: "graph_q_l_id") 63 | * } 64 | * 65 | * @example { requireUnderscorePrefixForIds: true } 66 | * // good 67 | * model PersistedQuery { 68 | * id String @id @map(name: "_id") 69 | * otherField String @map(name: "other_field") 70 | * } 71 | * 72 | * // bad 73 | * model PersistedQuery { 74 | * id String @id @map(name: "id") 75 | * otherField String @map(name: "other_field") 76 | * } 77 | * 78 | * @example enum 79 | * // good 80 | * enum RoleType { 81 | * ADMIN 82 | * MEMBER 83 | * } 84 | * 85 | * model UserRole { 86 | * roleType RoleType @map(name: "role_type") 87 | * } 88 | * 89 | * // bad 90 | * model UserRole { 91 | * roleType RoleType 92 | * } 93 | * 94 | * @example custom types 95 | * // good 96 | * type UserInfo { 97 | * institution String 98 | * } 99 | * 100 | * model User { 101 | * userInfo UserInfo @map(name: "user_info") 102 | * } 103 | * 104 | * // bad 105 | * model User { 106 | * userInfo UserInfo 107 | * } 108 | * 109 | * @example parameterized 110 | * // good 111 | * type Post { 112 | * /// prisma-lint-ignore-model field-name-mapping-snake-case tenantId 113 | * tenantId String 114 | * userId String @map(name: "user_id") 115 | * } 116 | * 117 | * // bad 118 | * type Post { 119 | * /// prisma-lint-ignore-model field-name-mapping-snake-case tenantId 120 | * tenantId String 121 | * userId String 122 | * } 123 | * 124 | */ 125 | export default { 126 | ruleName: RULE_NAME, 127 | configSchema: Config, 128 | create: (config, context) => { 129 | const { compoundWords, requireUnderscorePrefixForIds } = config ?? {}; 130 | return { 131 | Field: (model, field) => { 132 | if (getRuleIgnoreParams(model, RULE_NAME).includes(field.name)) return; 133 | 134 | const { fieldType } = field; 135 | if ( 136 | !isEnumField(context.enumNames, fieldType) && 137 | !isCustomTypeField(context.customTypeNames, fieldType) && 138 | looksLikeAssociationFieldType(fieldType) 139 | ) { 140 | return; 141 | } 142 | 143 | // A helper function to report a problem with the field. 144 | const report = (message: string) => 145 | context.report({ model, field, message }); 146 | 147 | const { attributes, name: fieldName } = field; 148 | const isIdWithRequiredPrefix = 149 | requireUnderscorePrefixForIds && 150 | attributes?.find((a) => a.name === 'id'); 151 | const isMappingRequired = 152 | !isAllLowerCase(fieldName) || isIdWithRequiredPrefix; 153 | 154 | if (!attributes) { 155 | if (isMappingRequired) { 156 | report('Field name must be mapped to snake case.'); 157 | } 158 | return; 159 | } 160 | const mapAttribute = findMapAttribute(attributes); 161 | if (!mapAttribute || !mapAttribute.args) { 162 | if (isMappingRequired) { 163 | report('Field name must be mapped to snake case.'); 164 | } 165 | return; 166 | } 167 | const mappedName = getMappedName(mapAttribute.args); 168 | if (!mappedName) { 169 | if (isMappingRequired) { 170 | report('Field name must be mapped to snake case.'); 171 | } 172 | return; 173 | } 174 | let expectedSnakeCase = toSnakeCase(fieldName, { compoundWords }); 175 | if (isIdWithRequiredPrefix) { 176 | expectedSnakeCase = `_${expectedSnakeCase}`; 177 | } 178 | if (mappedName !== expectedSnakeCase) { 179 | report(`Field name must be mapped to "${expectedSnakeCase}".`); 180 | } 181 | }, 182 | }; 183 | }, 184 | } satisfies FieldRuleDefinition>; 185 | 186 | function findMapAttribute(attributes: Attribute[]): Attribute | undefined { 187 | const filtered = attributes.filter((a) => a.name === 'map'); 188 | if (filtered.length === 0) { 189 | return; 190 | } 191 | if (filtered.length > 1) { 192 | throw Error( 193 | `Unexpected multiple map attributes! ${JSON.stringify(filtered)}`, 194 | ); 195 | } 196 | return filtered[0]; 197 | } 198 | 199 | function isAllLowerCase(s: string) { 200 | return s.toLowerCase() == s; 201 | } 202 | 203 | function isEnumField(enumNames: Set, fieldType: any) { 204 | if (typeof fieldType != 'string') { 205 | return false; 206 | } 207 | return enumNames.has(fieldType); 208 | } 209 | 210 | function isCustomTypeField(customTypeNames: Set, fieldType: any) { 211 | if (typeof fieldType != 'string') { 212 | return false; 213 | } 214 | return customTypeNames.has(fieldType); 215 | } 216 | -------------------------------------------------------------------------------- /src/rules/field-order.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import fieldOrder from '#src/rules/field-order.js'; 4 | 5 | describe('field-order', () => { 6 | const getRunner = (config: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'field-order': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [fieldOrder], 16 | }); 17 | 18 | describe('expecting tenant qid first', () => { 19 | const run = getRunner({ 20 | order: ['tenantQid', 'randomMissingField', '...'], 21 | }); 22 | 23 | describe('with tenant qid as first field', () => { 24 | it('returns no violations', async () => { 25 | const violations = await run(` 26 | model User { 27 | tenantQid String 28 | qid String 29 | email String 30 | } 31 | `); 32 | expect(violations.length).toEqual(0); 33 | }); 34 | }); 35 | 36 | describe('with no tenant qid', () => { 37 | it('returns no violations', async () => { 38 | const violations = await run(` 39 | model User { 40 | qid String 41 | email String 42 | } 43 | `); 44 | expect(violations.length).toEqual(0); 45 | }); 46 | }); 47 | 48 | describe('with tenant qid out of order', () => { 49 | it('returns violation', async () => { 50 | const violations = await run(` 51 | model Users { 52 | qid String 53 | tenantQid String 54 | email String 55 | } 56 | `); 57 | expect(violations.length).toEqual(1); 58 | }); 59 | }); 60 | }); 61 | describe('expecting tenant qid last', () => { 62 | const run = getRunner({ 63 | order: ['randomMissingField', '...', 'tenantQid'], 64 | }); 65 | 66 | describe('with tenant qid as last field', () => { 67 | it('returns no violations', async () => { 68 | const violations = await run(` 69 | model User { 70 | qid String 71 | email String 72 | tenantQid String 73 | } 74 | `); 75 | expect(violations.length).toEqual(0); 76 | }); 77 | }); 78 | 79 | describe('with no tenant qid', () => { 80 | it('returns no violations', async () => { 81 | const violations = await run(` 82 | model User { 83 | qid String 84 | email String 85 | } 86 | `); 87 | expect(violations.length).toEqual(0); 88 | }); 89 | }); 90 | 91 | describe('with tenant qid out of order', () => { 92 | it('returns violation', async () => { 93 | const violations = await run(` 94 | model Users { 95 | qid String 96 | tenantQid String 97 | email String 98 | } 99 | `); 100 | expect(violations.length).toEqual(1); 101 | }); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/rules/field-order.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { listFields } from '#src/common/prisma.js'; 4 | import type { ModelRuleDefinition } from '#src/common/rule.js'; 5 | 6 | const RULE_NAME = 'field-order'; 7 | 8 | const Config = z 9 | .object({ 10 | order: z.array(z.string()), 11 | }) 12 | .strict(); 13 | 14 | /** 15 | * Checks that fields within a model are in the correct order. 16 | * 17 | * Fields in the `order` list that are not present in the model are ignored. 18 | * (To require fields, use the `require-field` rule.) 19 | * 20 | * The first field in the `order` is interpreted to be required as 21 | * the first field in the model. The last field in the `order` is 22 | * interpreted to be required as the last field in the model. 23 | * 24 | * The special field name `...` can be used to indicate that any 25 | * number of fields can appear in the model at that point. This can 26 | * be used at the end of the `order` list to indicate that remaining 27 | * fields can appear in any order at the end of the model. 28 | * 29 | * @example { order: ['tenantId', 'id', createdAt', 'updatedAt', '...'] } 30 | * // good 31 | * model User { 32 | * tenantId String 33 | * id String @id 34 | * email String 35 | * } 36 | * 37 | * model User { 38 | * tenantId String 39 | * id String @id 40 | * createdAt DateTime 41 | * email String 42 | * } 43 | * 44 | * // bad 45 | * model User { 46 | * id String @id 47 | * email String 48 | * tenantId String 49 | * } 50 | * 51 | * @example { order: ['tenantId', 'id', '...', 'createdAt', 'updatedAt'] } 52 | * // good 53 | * model User { 54 | * tenantId String 55 | * id String 56 | * email String 57 | * createdAt DateTime 58 | * updatedAt DateTime 59 | * } 60 | * 61 | * model User { 62 | * tenantId String 63 | * id String 64 | * email String 65 | * createdAt DateTime 66 | * } 67 | * 68 | * // bad 69 | * model User { 70 | * id String @id 71 | * createdAt DateTime 72 | * email String 73 | * } 74 | * 75 | */ 76 | export default { 77 | ruleName: RULE_NAME, 78 | configSchema: Config, 79 | create: (config, context) => { 80 | const { order } = config; 81 | return { 82 | Model: (model) => { 83 | const fields = listFields(model); 84 | const fieldNameSet = new Set(fields.map((field) => field.name)); 85 | const actualNames = fields.map((field) => field.name); 86 | const expectedNames = order.filter( 87 | (name) => fieldNameSet.has(name) || name === '...', 88 | ); 89 | let e = 0; 90 | let a = 0; 91 | const outOfOrderFieldNames = []; 92 | while (e < expectedNames.length && a < actualNames.length) { 93 | const expectedName = expectedNames[e]; 94 | const nextExpectedName = expectedNames[e + 1]; 95 | const actualName = actualNames[a]; 96 | if (actualName === expectedName) { 97 | e += 1; 98 | a += 1; 99 | continue; 100 | } 101 | if (expectedName === '...') { 102 | if (actualName == nextExpectedName) { 103 | e += 1; 104 | continue; 105 | } else { 106 | a += 1; 107 | continue; 108 | } 109 | } 110 | outOfOrderFieldNames.push(actualName); 111 | a += 1; 112 | } 113 | if (a < actualNames.length) { 114 | outOfOrderFieldNames.push(...actualNames.slice(a)); 115 | } 116 | if (outOfOrderFieldNames.length === 0) { 117 | return; 118 | } 119 | const message = `Fields are not in the expected order: ${expectedNames 120 | .map((f) => `"${f}"`) 121 | .join(', ')}.`; 122 | context.report({ model, message }); 123 | }, 124 | }; 125 | }, 126 | } satisfies ModelRuleDefinition>; 127 | -------------------------------------------------------------------------------- /src/rules/forbid-field.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import forbidField from '#src/rules/forbid-field.js'; 4 | 5 | describe('forbid-field', () => { 6 | const getRunner = (config: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'forbid-field': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [forbidField], 16 | }); 17 | 18 | describe('forbidding with parameterized comment', () => { 19 | const run = getRunner({ 20 | forbid: ['id'], 21 | }); 22 | 23 | describe('with parameterized comment', () => { 24 | it('returns no violations', async () => { 25 | const violations = await run(` 26 | model User { 27 | /// prisma-lint-ignore-model forbid-field id 28 | id String 29 | } 30 | `); 31 | expect(violations.length).toEqual(0); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('forbidding id field with string', () => { 37 | const run = getRunner({ 38 | forbid: ['id'], 39 | }); 40 | 41 | describe('with uuid', () => { 42 | it('returns no violations', async () => { 43 | const violations = await run(` 44 | model User { 45 | uuid String 46 | } 47 | `); 48 | expect(violations.length).toEqual(0); 49 | }); 50 | }); 51 | 52 | describe('with id', () => { 53 | it('returns violation', async () => { 54 | const violations = await run(` 55 | model Users { 56 | id String 57 | } 58 | `); 59 | expect(violations.length).toEqual(1); 60 | }); 61 | }); 62 | }); 63 | 64 | describe('regex d6 fields without amount', () => { 65 | const run = getRunner({ 66 | forbid: ['/^(?!.*[aA]mountD6$).*D6$/'], 67 | }); 68 | 69 | describe('with amount d6', () => { 70 | it('returns no violations', async () => { 71 | const violations = await run(` 72 | model Product { 73 | priceAmountD6 Int 74 | amountD6 Int 75 | otherAmountD6 Int 76 | } 77 | `); 78 | expect(violations.length).toEqual(0); 79 | }); 80 | }); 81 | 82 | describe('without amount prefix', () => { 83 | it('returns violation', async () => { 84 | const violations = await run(` 85 | model Product { 86 | priceD6 Int 87 | } 88 | `); 89 | expect(violations.length).toEqual(1); 90 | }); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/rules/forbid-field.ts: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize'; 2 | import { z } from 'zod'; 3 | 4 | import { getRuleIgnoreParams } from '#src/common/ignore.js'; 5 | import { toRegExp } from '#src/common/regex.js'; 6 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 7 | 8 | const RULE_NAME = 'forbid-field'; 9 | 10 | const Config = z 11 | .object({ 12 | forbid: z.array(z.union([z.string(), z.instanceof(RegExp)])), 13 | }) 14 | .strict(); 15 | 16 | /** 17 | * Forbids fields with certain names. 18 | * 19 | * @example { forbid: ["id"] } 20 | * // good 21 | * type Product { 22 | * uuid String 23 | * } 24 | * 25 | * // bad 26 | * type Product { 27 | * id String 28 | * } 29 | * 30 | * 31 | * @example { forbid: ["/^(?!.*[aA]mountD6$).*D6$/"] } 32 | * // good 33 | * type Product { 34 | * id String 35 | * priceAmountD6 Int 36 | * } 37 | * 38 | * // bad 39 | * type Product { 40 | * id Int 41 | * priceD6 Int 42 | * } 43 | * 44 | */ 45 | export default { 46 | ruleName: RULE_NAME, 47 | configSchema: Config, 48 | create: (config, context) => { 49 | const forbidWithRegExp = config.forbid.map((name) => ({ 50 | name, 51 | nameRegExp: toRegExp(name), 52 | })); 53 | return { 54 | Field: (model, field) => { 55 | const ruleIgnoreParams = getRuleIgnoreParams(model, RULE_NAME); 56 | const ignoreNameSet = new Set(ruleIgnoreParams); 57 | if (ignoreNameSet.has(field.name)) { 58 | return; 59 | } 60 | 61 | const matches = forbidWithRegExp.filter((r) => 62 | r.nameRegExp.test(field.name), 63 | ); 64 | if (matches.length === 0) { 65 | return; 66 | } 67 | const message = `Field "${field.name}" is forbid by ${pluralize( 68 | 'rule', 69 | matches.length, 70 | )}: ${matches.map((m) => `"${m.name}"`).join(', ')}.`; 71 | context.report({ model, field, message }); 72 | }, 73 | }; 74 | }, 75 | } satisfies FieldRuleDefinition>; 76 | -------------------------------------------------------------------------------- /src/rules/forbid-required-ignored-field.test.ts: -------------------------------------------------------------------------------- 1 | import { testLintPrismaSource } from '#src/common/test.js'; 2 | import forbidRequiredIgnoredField from '#src/rules/forbid-required-ignored-field.js'; 3 | 4 | describe('forbid-required-ignored-field', () => { 5 | const getRunner = () => async (sourceCode: string) => 6 | await testLintPrismaSource({ 7 | fileName: 'fake.ts', 8 | sourceCode, 9 | rootConfig: { 10 | rules: { 11 | 'forbid-required-ignored-field': ['error'], 12 | }, 13 | }, 14 | ruleDefinitions: [forbidRequiredIgnoredField], 15 | }); 16 | 17 | describe('forbidding', () => { 18 | const run = getRunner(); 19 | 20 | describe('with ignored relation-only field', () => { 21 | it('returns no violations', async () => { 22 | const violations = await run(` 23 | model User { 24 | id Int @id @default(autoincrement()) 25 | posts Post[] @ignore 26 | } 27 | 28 | model Post { 29 | id Int @id @default(autoincrement()) 30 | author User @relation(fields: [authorId], references: [id]) 31 | authorId Int 32 | title String 33 | @@ignore 34 | } 35 | `); 36 | expect(violations.length).toEqual(0); 37 | }); 38 | }); 39 | 40 | describe('with optional ignored field', () => { 41 | it('returns no violations', async () => { 42 | const violations = await run(` 43 | model User { 44 | id String 45 | toBeRemoved String? @ignore 46 | } 47 | `); 48 | expect(violations.length).toEqual(0); 49 | }); 50 | }); 51 | 52 | describe('with required ignored field with default', () => { 53 | it('returns no violations', async () => { 54 | const violations = await run(` 55 | model Users { 56 | id String 57 | deleted String @ignore @default(false) 58 | } 59 | `); 60 | expect(violations.length).toEqual(0); 61 | }); 62 | }); 63 | 64 | describe('with required ignored field', () => { 65 | it('returns violation', async () => { 66 | const violations = await run(` 67 | model Users { 68 | id String 69 | toBeRemoved String @ignore 70 | } 71 | `); 72 | expect(violations.length).toEqual(1); 73 | }); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/rules/forbid-required-ignored-field.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { looksLikeAssociationFieldType } from '#src/common/prisma.js'; 4 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 5 | 6 | const RULE_NAME = 'forbid-required-ignored-field'; 7 | 8 | const Config = z.object({}).strict().optional(); 9 | 10 | /** 11 | * Forbids required ignored fields without default values. 12 | * 13 | * This prevents a client from being generated without a field while 14 | * the database still expects the corresponding column to be non-nullable. 15 | * 16 | * For more protection against breaking changes, consider using: 17 | * 18 | * 19 | * 20 | * @example 21 | * // good 22 | * type Product { 23 | * uuid String 24 | * toBeRemoved String? @ignore 25 | * } 26 | * 27 | * // good 28 | * type Product { 29 | * uuid String 30 | * toBeRemoved Boolean @default(false) @ignore 31 | * } 32 | * 33 | * // bad 34 | * type Product { 35 | * uuid String 36 | * toBeRemoved String @ignore 37 | * } 38 | */ 39 | export default { 40 | ruleName: RULE_NAME, 41 | configSchema: Config, 42 | create: (_, context) => { 43 | return { 44 | Field: (model, field) => { 45 | const isIgnored = field?.attributes?.some( 46 | (attr) => attr.name === 'ignore', 47 | ); 48 | const hasDefault = field?.attributes?.some( 49 | (attr) => attr.name === 'default', 50 | ); 51 | if (!isIgnored || hasDefault) return; 52 | const isRequired = !field.optional; 53 | if (!isRequired) return; 54 | if (looksLikeAssociationFieldType(field.fieldType)) { 55 | return; 56 | } 57 | const message = 58 | 'Do not ignore a required field without a default value.'; 59 | context.report({ model, field, message }); 60 | }, 61 | }; 62 | }, 63 | } satisfies FieldRuleDefinition>; 64 | -------------------------------------------------------------------------------- /src/rules/model-name-grammatical-number.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import modelNameGrammaticalNumber from '#src/rules/model-name-grammatical-number.js'; 4 | 5 | describe('model-name-grammatical-number', () => { 6 | const getRunner = (config: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'model-name-grammatical-number': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [modelNameGrammaticalNumber], 16 | }); 17 | 18 | describe('ignore comments', () => { 19 | const run = getRunner({ style: 'singular' }); 20 | 21 | it('respects rule-specific ignore comments', async () => { 22 | const violations = await run(` 23 | model Users { 24 | /// prisma-lint-ignore-model model-name-grammatical-number 25 | id String @id 26 | } 27 | `); 28 | expect(violations.length).toEqual(0); 29 | }); 30 | 31 | it('respects model-wide ignore comments', async () => { 32 | const violations = await run(` 33 | model Users { 34 | /// prisma-lint-ignore-model 35 | id String @id 36 | } 37 | `); 38 | expect(violations.length).toEqual(0); 39 | }); 40 | }); 41 | 42 | describe('expecting singular', () => { 43 | const run = getRunner({ style: 'singular' }); 44 | 45 | describe('singular', () => { 46 | it('returns no violations', async () => { 47 | const violations = await run(` 48 | model User { 49 | id String @id 50 | } 51 | `); 52 | expect(violations.length).toEqual(0); 53 | }); 54 | }); 55 | 56 | describe('plural', () => { 57 | it('returns violation', async () => { 58 | const violations = await run(` 59 | model Users { 60 | id String @id 61 | } 62 | `); 63 | expect(violations.length).toEqual(1); 64 | }); 65 | }); 66 | }); 67 | 68 | describe('expecting plural', () => { 69 | const run = getRunner({ style: 'plural' }); 70 | describe('singular', () => { 71 | it('returns violation', async () => { 72 | const violations = await run(` 73 | model User { 74 | id String @id 75 | } 76 | `); 77 | expect(violations.length).toEqual(1); 78 | }); 79 | }); 80 | 81 | describe('plural', () => { 82 | it('returns no violations', async () => { 83 | const violations = await run(` 84 | model Users { 85 | id String @id 86 | } 87 | `); 88 | expect(violations.length).toEqual(0); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('allowlist', () => { 94 | describe('without allowlist', () => { 95 | const run = getRunner({ style: 'singular' }); 96 | 97 | it('returns violation', async () => { 98 | const violations = await run(` 99 | model UserData { 100 | id String @id 101 | } 102 | `); 103 | expect(violations.length).toEqual(1); 104 | }); 105 | }); 106 | 107 | describe('with string allowlist', () => { 108 | const run = getRunner({ style: 'singular', allowlist: ['UserData'] }); 109 | 110 | it('returns no violations', async () => { 111 | const violations = await run(` 112 | model UserData { 113 | id String @id 114 | } 115 | `); 116 | expect(violations.length).toEqual(0); 117 | }); 118 | }); 119 | 120 | describe('with regexp allowlist', () => { 121 | const run = getRunner({ style: 'singular', allowlist: ['/Data$/'] }); 122 | 123 | describe('with matching suffix', () => { 124 | it('returns no violations', async () => { 125 | const violations = await run(` 126 | model UserData { 127 | id String @id 128 | } 129 | model TenantData { 130 | id String @id 131 | } 132 | `); 133 | expect(violations.length).toEqual(0); 134 | }); 135 | }); 136 | 137 | describe('without matching suffix', () => { 138 | it('returns violation', async () => { 139 | const violations = await run(` 140 | model DataRecords { 141 | id String @id 142 | } 143 | `); 144 | expect(violations.length).toEqual(1); 145 | }); 146 | }); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /src/rules/model-name-grammatical-number.ts: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize'; 2 | 3 | import { z } from 'zod'; 4 | 5 | import { isRegexOrRegexStr, toRegExp } from '#src/common/regex.js'; 6 | import type { ModelRuleDefinition } from '#src/common/rule.js'; 7 | 8 | const RULE_NAME = 'model-name-grammatical-number'; 9 | 10 | const Config = z 11 | .object({ 12 | style: z.enum(['singular', 'plural']), 13 | allowlist: z.array(z.union([z.string(), z.instanceof(RegExp)])).optional(), 14 | }) 15 | .strict(); 16 | 17 | /** 18 | * Checks that each model name matches the plural or singlar enforced style. 19 | * 20 | * 21 | * 22 | * @example { style: "singular" } 23 | * // good 24 | * model User { 25 | * id String @id 26 | * } 27 | * 28 | * // bad 29 | * model Users { 30 | * id String @id 31 | * } 32 | * 33 | * @example { style: "plural" } 34 | * // good 35 | * model Users { 36 | * id String @id 37 | * } 38 | * 39 | * // bad 40 | * model User { 41 | * id String @id 42 | * } 43 | * 44 | * @example { style: "singular", allowlist: ["UserData"] } 45 | * // good 46 | * model UserData { 47 | * id String @id 48 | * } 49 | * 50 | * model User { 51 | * id String @id 52 | * } 53 | * 54 | * model Tenant { 55 | * id String @id 56 | * } 57 | * 58 | * // bad ("data" is considered plural by default) 59 | * model TenantData { 60 | * id String @id 61 | * } 62 | * 63 | * model Users { 64 | * id String @id 65 | * } 66 | * 67 | * @example { style: "singular", allowlist: ["/Data$/"] } 68 | * // good 69 | * model UserData { 70 | * id String @id 71 | * } 72 | * 73 | * model TenantData { 74 | * id String @id 75 | * } 76 | * 77 | * // bad 78 | * model DataRecords { 79 | * id String @id 80 | * } 81 | * 82 | * model Users { 83 | * id String @id 84 | * } 85 | */ 86 | export default { 87 | ruleName: RULE_NAME, 88 | configSchema: Config, 89 | create: (config, context) => { 90 | const { style } = config; 91 | const allowlist = config.allowlist ?? []; 92 | const simpleAllowlist = allowlist.filter((s) => !isRegexOrRegexStr(s)); 93 | const regexAllowlist = allowlist 94 | .filter((s) => isRegexOrRegexStr(s)) 95 | .map(toRegExp); 96 | return { 97 | Model: (model) => { 98 | const modelName = model.name; 99 | if (simpleAllowlist.includes(modelName)) { 100 | return; 101 | } 102 | if (regexAllowlist.some((r) => r.test(modelName))) { 103 | return; 104 | } 105 | const isPlural = pluralize.isPlural(modelName); 106 | if (isPlural && style === 'singular') { 107 | context.report({ model, message: 'Expected singular model name.' }); 108 | } 109 | if (!isPlural && style === 'plural') { 110 | context.report({ model, message: 'Expected plural model name.' }); 111 | } 112 | }, 113 | }; 114 | }, 115 | } satisfies ModelRuleDefinition>; 116 | -------------------------------------------------------------------------------- /src/rules/model-name-mapping-snake-case.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import modelNameMappingSnakeCase from '#src/rules/model-name-mapping-snake-case.js'; 4 | 5 | describe('model-name-mapping-snake-case', () => { 6 | const getRunner = (config?: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'model-name-mapping-snake-case': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [modelNameMappingSnakeCase], 16 | }); 17 | 18 | describe('without config', () => { 19 | const run = getRunner(); 20 | 21 | describe('valid with key', () => { 22 | it('returns no violations', async () => { 23 | const violations = await run(` 24 | model UserRole { 25 | id String @id 26 | @@map(name: "user_role") 27 | } 28 | `); 29 | expect(violations.length).toEqual(0); 30 | }); 31 | }); 32 | 33 | describe('single word name with mapping', () => { 34 | it('returns no violations', async () => { 35 | const violations = await run(` 36 | model User { 37 | id String @id 38 | @@map(name: "user") 39 | } 40 | `); 41 | expect(violations.length).toEqual(0); 42 | }); 43 | }); 44 | 45 | describe('single word name without mapping', () => { 46 | it('returns violation', async () => { 47 | const violations = await run(` 48 | model User { 49 | id String @id 50 | } 51 | `); 52 | expect(violations.length).toEqual(1); 53 | }); 54 | }); 55 | 56 | describe('valid with no key', () => { 57 | it('returns no violations', async () => { 58 | const violations = await run(` 59 | model UserRole { 60 | id String @id 61 | @@map("user_role") 62 | } 63 | `); 64 | expect(violations.length).toEqual(0); 65 | }); 66 | }); 67 | 68 | describe('missing @@map', () => { 69 | it('returns violation', async () => { 70 | const violations = await run(` 71 | model UserRole { 72 | id String @id 73 | } 74 | `); 75 | expect(violations.length).toEqual(1); 76 | }); 77 | }); 78 | 79 | describe('@@map has no name', () => { 80 | it('returns violation', async () => { 81 | const violations = await run(` 82 | model UserRole { 83 | id String @id 84 | @@map(other: "user_role") 85 | } 86 | `); 87 | expect(violations.length).toEqual(1); 88 | }); 89 | }); 90 | }); 91 | 92 | describe('with compound words', () => { 93 | const run = getRunner({ 94 | compoundWords: ['Q6', 'QU', 'QX', 'BR', 'LT', 'QP', 'L5', 'TK', 'QT'], 95 | }); 96 | 97 | it('returns violation', async () => { 98 | const violations = await run(` 99 | model GameBRTicketLostSequence { 100 | id String @id 101 | @@map(name: "game_br_ticket_lost_sequence") 102 | } 103 | `); 104 | expect(violations).toMatchObject([]); 105 | }); 106 | }); 107 | 108 | describe('expecting plural names', () => { 109 | const run = getRunner({ pluralize: true }); 110 | 111 | describe('with a plural name', () => { 112 | it('returns no violations', async () => { 113 | const violations = await run(` 114 | model UserRole { 115 | id String @id 116 | @@map(name: "user_roles") 117 | } 118 | `); 119 | expect(violations.length).toEqual(0); 120 | }); 121 | }); 122 | 123 | describe('with a singular name', () => { 124 | it('returns violation', async () => { 125 | const violations = await run(` 126 | model UserRole { 127 | id String @id 128 | @@map(name: "user_role") 129 | } 130 | `); 131 | expect(violations.length).toEqual(1); 132 | }); 133 | }); 134 | }); 135 | 136 | describe('expecting irregular plural names', () => { 137 | const run = getRunner({ 138 | pluralize: true, 139 | irregularPlurals: { bill_of_lading: 'bills_of_lading' }, 140 | }); 141 | 142 | describe('with a plural name', () => { 143 | it('returns no violations', async () => { 144 | const violations = await run(` 145 | model BillOfLading { 146 | id String @id 147 | @@map(name: "bills_of_lading") 148 | } 149 | `); 150 | expect(violations.length).toEqual(0); 151 | }); 152 | }); 153 | 154 | describe('with a singular name', () => { 155 | it('returns violation', async () => { 156 | const violations = await run(` 157 | model BillOfLading { 158 | id String @id 159 | @@map(name: "bill_of_lading") 160 | } 161 | `); 162 | expect(violations.length).toEqual(1); 163 | }); 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/rules/model-name-mapping-snake-case.ts: -------------------------------------------------------------------------------- 1 | import type { BlockAttribute } from '@mrleebo/prisma-ast'; 2 | 3 | import { z } from 'zod'; 4 | 5 | import { getMappedName, listAttributes } from '#src/common/prisma.js'; 6 | import type { ModelRuleDefinition } from '#src/common/rule.js'; 7 | import { toSnakeCase } from '#src/common/snake-case.js'; 8 | 9 | const RULE_NAME = 'model-name-mapping-snake-case'; 10 | 11 | const Config = z 12 | .object({ 13 | compoundWords: z.array(z.string()).optional(), 14 | irregularPlurals: z.record(z.string()).optional(), 15 | pluralize: z.boolean().optional(), 16 | trimPrefix: z.string().optional(), 17 | }) 18 | .strict() 19 | .optional(); 20 | 21 | /** 22 | * Checks that the mapped name of a model is the expected snake case. 23 | * 24 | * @example 25 | * // good 26 | * model UserRole { 27 | * id String @id 28 | * @@map(name: "user_role") 29 | * } 30 | * 31 | * // bad 32 | * model UserRole { 33 | * id String @id 34 | * } 35 | * 36 | * model UserRole { 37 | * id String @id 38 | * @@map(name: "user_roles") 39 | * } 40 | * 41 | * 42 | * @example { trimPrefix: "Db" } 43 | * // good 44 | * model DbUserRole { 45 | * id String @id 46 | * @@map(name: "user_role") 47 | * } 48 | * 49 | * // bad 50 | * model DbUserRole { 51 | * id String @id 52 | * @@map(name: "db_user_role") 53 | * } 54 | * 55 | * 56 | * @example { compoundWords: ["GraphQL"] } 57 | * // good 58 | * model GraphQLPersistedQuery { 59 | * id String @id 60 | * @@map(name: "graphql_persisted_query") 61 | * } 62 | * 63 | * // bad 64 | * model GraphQLPersistedQuery { 65 | * id String @id 66 | * @@map(name: "graph_q_l_persisted_query") 67 | * } 68 | * 69 | * 70 | * @example { pluralize: true } 71 | * // good 72 | * model UserRole { 73 | * id String @id 74 | * @@map(name: "user_roles") 75 | * } 76 | * 77 | * // bad 78 | * model UserRole { 79 | * id String @id 80 | * } 81 | * 82 | * model UserRole { 83 | * id String @id 84 | * @@map(name: "user_role") 85 | * } 86 | * 87 | */ 88 | export default { 89 | ruleName: RULE_NAME, 90 | configSchema: Config, 91 | create: (config, context) => { 92 | const compoundWords = config?.compoundWords ?? []; 93 | const trimPrefix = config?.trimPrefix ?? ''; 94 | const shouldPluralize = config?.pluralize ?? false; 95 | const irregularPlurals = config?.irregularPlurals ?? {}; 96 | return { 97 | Model: (model) => { 98 | const attributes = listAttributes(model); 99 | const mapAttribute = findMapAttribute(attributes); 100 | if (!mapAttribute) { 101 | context.report({ 102 | model, 103 | message: 'Model name must be mapped to snake case.', 104 | }); 105 | return; 106 | } 107 | const mappedName = getMappedName(mapAttribute.args); 108 | if (!mappedName) { 109 | context.report({ 110 | model, 111 | message: 'Model name must be mapped to snake case.', 112 | }); 113 | return; 114 | } 115 | const nodeName = model.name; 116 | const expectedSnakeCase = toSnakeCase(nodeName, { 117 | compoundWords, 118 | trimPrefix, 119 | pluralize: shouldPluralize, 120 | irregularPlurals, 121 | }); 122 | if (mappedName !== expectedSnakeCase) { 123 | context.report({ 124 | model, 125 | message: `Model name must be mapped to "${expectedSnakeCase}".`, 126 | }); 127 | } 128 | }, 129 | }; 130 | }, 131 | } satisfies ModelRuleDefinition>; 132 | 133 | function findMapAttribute( 134 | attributes: BlockAttribute[], 135 | ): BlockAttribute | undefined { 136 | const filtered = attributes.filter((a) => a.name === 'map'); 137 | if (filtered.length === 0) { 138 | return; 139 | } 140 | if (filtered.length > 1) { 141 | throw Error( 142 | `Unexpected multiple map attributes! ${JSON.stringify(filtered)}`, 143 | ); 144 | } 145 | return filtered[0]; 146 | } 147 | -------------------------------------------------------------------------------- /src/rules/model-name-pascal-case.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import modelNamePascalCase from '#src/rules/model-name-pascal-case.js'; 4 | 5 | describe('model-name-pascal-case', () => { 6 | const getRunner = (config?: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'model-name-pascal-case': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [modelNamePascalCase], 16 | }); 17 | 18 | describe('ignore comments', () => { 19 | const run = getRunner(); 20 | 21 | it('respects rule-specific ignore comments', async () => { 22 | const violations = await run(` 23 | model users { 24 | /// prisma-lint-ignore-model model-name-pascal-case 25 | id String @id 26 | } 27 | `); 28 | expect(violations.length).toEqual(0); 29 | }); 30 | 31 | it('respects model-wide ignore comments', async () => { 32 | const violations = await run(` 33 | model users { 34 | /// prisma-lint-ignore-model 35 | id String @id 36 | } 37 | `); 38 | expect(violations.length).toEqual(0); 39 | }); 40 | }); 41 | 42 | describe('without config', () => { 43 | const run = getRunner(); 44 | 45 | describe('single word', () => { 46 | it('returns no violations', async () => { 47 | const violations = await run(` 48 | model User { 49 | id String @id 50 | } 51 | `); 52 | expect(violations.length).toEqual(0); 53 | }); 54 | }); 55 | 56 | describe('compound word in PascalCase', () => { 57 | it('returns no violations', async () => { 58 | const violations = await run(` 59 | model DbUsers { 60 | id String @id 61 | } 62 | `); 63 | expect(violations.length).toEqual(0); 64 | }); 65 | }); 66 | 67 | describe('first character is not uppercase', () => { 68 | it('returns violation', async () => { 69 | const violations = await run(` 70 | model dbUsers { 71 | id String @id 72 | } 73 | `); 74 | expect(violations.length).toEqual(1); 75 | }); 76 | }); 77 | 78 | describe('contains underscore', () => { 79 | it('returns violation', async () => { 80 | const violations = await run(` 81 | model DB_Users { 82 | id String @id 83 | } 84 | `); 85 | expect(violations.length).toEqual(1); 86 | }); 87 | }); 88 | 89 | describe('permits digits anywhere', () => { 90 | it('returns no violations', async () => { 91 | const violations = await run(` 92 | model Db2Us3rs { 93 | id String @id 94 | } 95 | `); 96 | expect(violations.length).toEqual(0); 97 | }); 98 | }); 99 | }); 100 | 101 | describe('with allowlist string', () => { 102 | const run = getRunner({ allowList: ['dbUser'] }); 103 | 104 | it('returns no violations', async () => { 105 | const violations = await run(` 106 | model dbUser { 107 | id String @id 108 | } 109 | `); 110 | expect(violations.length).toEqual(0); 111 | }); 112 | 113 | it('allowList string requires full match', async () => { 114 | const violations = await run(` 115 | model dbUsersWithSuffix { 116 | id String @id 117 | } 118 | `); 119 | expect(violations.length).toEqual(1); 120 | }); 121 | }); 122 | 123 | describe('with allowlist regexp', () => { 124 | const run = getRunner({ allowList: [/^db.*/] }); 125 | 126 | it('returns no violations', async () => { 127 | const violations = await run(` 128 | model db_user { 129 | id String @id 130 | } 131 | `); 132 | expect(violations.length).toEqual(0); 133 | }); 134 | }); 135 | 136 | describe('with trimPrefix single string', () => { 137 | const run = getRunner({ trimPrefix: 'db' }); 138 | 139 | it('returns no violations', async () => { 140 | const violations = await run(` 141 | model dbUser { 142 | id String @id 143 | } 144 | `); 145 | expect(violations.length).toEqual(0); 146 | }); 147 | 148 | it('remaining suffix must be PascalCase', async () => { 149 | const violations = await run(` 150 | model dbcamelCase { 151 | id String @id 152 | } 153 | `); 154 | expect(violations.length).toEqual(1); 155 | }); 156 | }); 157 | 158 | describe('with trimPrefix single regexp', () => { 159 | const run = getRunner({ trimPrefix: /^db/ }); 160 | 161 | it('returns no violations', async () => { 162 | const violations = await run(` 163 | model dbUser { 164 | id String @id 165 | } 166 | `); 167 | expect(violations.length).toEqual(0); 168 | }); 169 | 170 | it('remaining suffix must be PascalCase', async () => { 171 | const violations = await run(` 172 | model dbcamelCase { 173 | id String @id 174 | } 175 | `); 176 | expect(violations.length).toEqual(1); 177 | }); 178 | }); 179 | 180 | describe('with trimPrefix array', () => { 181 | const run = getRunner({ trimPrefix: ['db', /^eg/] }); 182 | 183 | it('returns no violations for first prefix', async () => { 184 | const violations = await run(` 185 | model dbUser { 186 | id String @id 187 | } 188 | `); 189 | expect(violations.length).toEqual(0); 190 | }); 191 | 192 | it('returns no violations for second prefix', async () => { 193 | const violations = await run(` 194 | model egUser { 195 | id String @id 196 | } 197 | `); 198 | expect(violations.length).toEqual(0); 199 | }); 200 | 201 | it('remaining suffix after string must be PascalCase', async () => { 202 | const violations = await run(` 203 | model dbcamelCase { 204 | id String @id 205 | } 206 | `); 207 | expect(violations.length).toEqual(1); 208 | }); 209 | 210 | it('remaining suffix after regexp must be PascalCase', async () => { 211 | const violations = await run(` 212 | model egcamelCase { 213 | id String @id 214 | } 215 | `); 216 | expect(violations.length).toEqual(1); 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/rules/model-name-pascal-case.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { 4 | matchesAllowList, 5 | trimPrefix, 6 | } from '#src/common/rule-config-helpers.js'; 7 | import type { ModelRuleDefinition } from '#src/common/rule.js'; 8 | 9 | const RULE_NAME = 'model-name-pascal-case'; 10 | 11 | const Config = z 12 | .object({ 13 | allowList: z.array(z.union([z.string(), z.instanceof(RegExp)])).optional(), 14 | trimPrefix: z 15 | .union([ 16 | z.string(), 17 | z.instanceof(RegExp), 18 | z.array(z.union([z.string(), z.instanceof(RegExp)])), 19 | ]) 20 | .optional(), 21 | }) 22 | .strict(); 23 | 24 | /** 25 | * Checks that model names are in PascalCase. 26 | * 27 | * @example 28 | * // good 29 | * model DbUser { 30 | * id String @id 31 | * } 32 | * 33 | * // bad 34 | * model dbUser { 35 | * id String @id 36 | * } 37 | * 38 | * // bad 39 | * model db_user { 40 | * id String @id 41 | * } 42 | * 43 | */ 44 | export default { 45 | ruleName: RULE_NAME, 46 | configSchema: Config, 47 | create: (config, context) => { 48 | const { allowList, trimPrefix: trimPrefixConfig } = config; 49 | return { 50 | Model: (model) => { 51 | if (matchesAllowList(model.name, allowList)) { 52 | return; 53 | } 54 | const nameWithoutPrefix = trimPrefix(model.name, trimPrefixConfig); 55 | if (!nameWithoutPrefix.match(/^[A-Z][a-zA-Z0-9]*$/)) { 56 | const message = 'Model name should be in PascalCase.'; 57 | context.report({ model, message }); 58 | } 59 | }, 60 | }; 61 | }, 62 | } satisfies ModelRuleDefinition>; 63 | -------------------------------------------------------------------------------- /src/rules/model-name-prefix.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import modelNamePrefix from '#src/rules/model-name-prefix.js'; 4 | 5 | describe('model-name-prefix', () => { 6 | const getRunner = (config: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'model-name-prefix': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [modelNamePrefix], 16 | }); 17 | 18 | describe('ignore comments', () => { 19 | const run = getRunner({ prefix: 'Db' }); 20 | 21 | it('respects rule-specific ignore comments', async () => { 22 | const violations = await run(` 23 | model Users { 24 | /// prisma-lint-ignore-model model-name-prefix 25 | id String @id 26 | } 27 | `); 28 | expect(violations.length).toEqual(0); 29 | }); 30 | 31 | it('respects model-wide ignore comments', async () => { 32 | const violations = await run(` 33 | model Users { 34 | /// prisma-lint-ignore-model 35 | id String @id 36 | } 37 | `); 38 | expect(violations.length).toEqual(0); 39 | }); 40 | }); 41 | 42 | describe('expecting Db', () => { 43 | const run = getRunner({ prefix: 'Db' }); 44 | 45 | describe('with prefix', () => { 46 | it('returns no violations', async () => { 47 | const violations = await run(` 48 | model DbUser { 49 | id String @id 50 | } 51 | `); 52 | expect(violations.length).toEqual(0); 53 | }); 54 | }); 55 | 56 | describe('without prefix', () => { 57 | it('returns violation', async () => { 58 | const violations = await run(` 59 | model Users { 60 | id String @id 61 | } 62 | `); 63 | expect(violations.length).toEqual(1); 64 | }); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/rules/model-name-prefix.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import type { ModelRuleDefinition } from '#src/common/rule.js'; 4 | 5 | const RULE_NAME = 'model-name-prefix'; 6 | 7 | const Config = z 8 | .object({ 9 | prefix: z.string(), 10 | }) 11 | .strict(); 12 | 13 | /** 14 | * Checks that model names include a required prefix. 15 | * 16 | * This is useful for avoiding name collisions with 17 | * application-level types in cases where a single 18 | * domain object is persisted in multiple tables, 19 | * and the application type differs from the table 20 | * structure. 21 | * 22 | * @example { prefix: "Db" } 23 | * // good 24 | * model DbUser { 25 | * id String @id 26 | * } 27 | * 28 | * // bad 29 | * model Users { 30 | * id String @id 31 | * } 32 | * 33 | */ 34 | export default { 35 | ruleName: RULE_NAME, 36 | configSchema: Config, 37 | create: (config, context) => { 38 | const { prefix } = config; 39 | return { 40 | Model: (model) => { 41 | if (model.name.startsWith(prefix)) { 42 | return; 43 | } 44 | const message = `Model name should start with "${prefix}".`; 45 | context.report({ model, message }); 46 | }, 47 | }; 48 | }, 49 | } satisfies ModelRuleDefinition>; 50 | -------------------------------------------------------------------------------- /src/rules/require-default-empty-arrays.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import requireDefaultEmptyArrays from '#src/rules/require-default-empty-arrays.js'; 4 | 5 | describe('require-default-empty-arrays', () => { 6 | const getRunner = (config?: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'require-default-empty-arrays': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [requireDefaultEmptyArrays], 16 | }); 17 | 18 | describe('without config', () => { 19 | const run = getRunner(); 20 | 21 | describe('valid default empty array', () => { 22 | it('returns no violations', async () => { 23 | const violations = await run(` 24 | model Post { 25 | tags String[] @default([]) 26 | } 27 | `); 28 | expect(violations.length).toEqual(0); 29 | }); 30 | }); 31 | 32 | describe('no default', () => { 33 | it('returns violations', async () => { 34 | const violations = await run(` 35 | model Post { 36 | tags String[] 37 | } 38 | `); 39 | expect(violations.length).toEqual(1); 40 | }); 41 | }); 42 | 43 | describe('non-array default', () => { 44 | it('returns violations', async () => { 45 | const violations = await run(` 46 | model Post { 47 | tags String[] @default("foo") 48 | } 49 | `); 50 | expect(violations.length).toEqual(1); 51 | }); 52 | }); 53 | 54 | describe('not empty array default', () => { 55 | it('returns violations', async () => { 56 | const violations = await run(` 57 | model Post { 58 | tags String[] @default(["foo"]) 59 | } 60 | `); 61 | expect(violations.length).toEqual(1); 62 | }); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/rules/require-default-empty-arrays.ts: -------------------------------------------------------------------------------- 1 | import type { Attribute, Field } from '@mrleebo/prisma-ast'; 2 | 3 | import { z } from 'zod'; 4 | 5 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 6 | 7 | const RULE_NAME = 'require-default-empty-arrays'; 8 | 9 | const Config = z.object({}).strict().optional(); 10 | 11 | /** 12 | * Requires default empty arrays for array fields. 13 | * 14 | * Motivation: 15 | * 16 | * 17 | * @example 18 | * // good 19 | * model Post { 20 | * tags String[] @default([]) 21 | * } 22 | * 23 | * // bad 24 | * model Post { 25 | * tags String[] 26 | * } 27 | * 28 | */ 29 | export default { 30 | ruleName: RULE_NAME, 31 | configSchema: Config, 32 | create: (_, context) => { 33 | return { 34 | Field: (model, field) => { 35 | if (hasViolation(field)) { 36 | context.report({ 37 | model, 38 | field, 39 | message: 'Array field must default to empty array.', 40 | }); 41 | } 42 | }, 43 | }; 44 | }, 45 | } satisfies FieldRuleDefinition>; 46 | 47 | function hasViolation(field: Field): boolean { 48 | const { attributes, array } = field; 49 | if (!array) { 50 | return false; 51 | } 52 | if (!attributes) { 53 | return true; 54 | } 55 | const defaultAttribute = findDefaultAttribute(attributes); 56 | if (defaultAttribute == null || defaultAttribute.args == null) { 57 | return true; 58 | } 59 | const { args } = defaultAttribute; 60 | if (args.length === 0) { 61 | return true; 62 | } 63 | const [firstArg] = args; 64 | const firstArgValue = firstArg.value; 65 | if (firstArgValue == null) { 66 | return true; 67 | } 68 | if (typeof firstArgValue !== 'object') { 69 | return true; 70 | } 71 | if ((firstArgValue as any).type !== 'array') { 72 | return true; 73 | } 74 | if ((firstArgValue as any).args != null) { 75 | return true; 76 | } 77 | return false; 78 | } 79 | 80 | function findDefaultAttribute(attributes: Attribute[]): Attribute | undefined { 81 | const filtered = attributes.filter((a) => a.name === 'default'); 82 | if (filtered.length === 0) { 83 | return; 84 | } 85 | if (filtered.length > 1) { 86 | throw Error( 87 | `Unexpected multiple default attributes! ${JSON.stringify(filtered)}`, 88 | ); 89 | } 90 | return filtered[0]; 91 | } 92 | -------------------------------------------------------------------------------- /src/rules/require-field-index.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import requireFieldIndex from '#src/rules/require-field-index.js'; 4 | 5 | describe('require-field-index', () => { 6 | const getRunner = (config: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'require-field-index': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [requireFieldIndex], 16 | }); 17 | 18 | describe('ignore comments', () => { 19 | const run = getRunner({ 20 | forNames: ['qid', 'tenantQid', 'createdAt'], 21 | }); 22 | 23 | it('respects rule-specific ignore comments', async () => { 24 | const violations = await run(` 25 | model Products { 26 | /// prisma-lint-ignore-model require-field-index 27 | tenantQid String 28 | } 29 | `); 30 | expect(violations.length).toEqual(0); 31 | }); 32 | 33 | it('respects field-specific ignore comments with comma', async () => { 34 | const violations = await run(` 35 | model Products { 36 | /// prisma-lint-ignore-model require-field-index tenantQid,createdAt 37 | tenantQid String 38 | createdAt DateTime 39 | qid String 40 | } 41 | `); 42 | expect(violations.length).toEqual(1); 43 | expect(violations[0].message).toContain('qid'); 44 | expect(violations[0].message).not.toContain('tenantQid'); 45 | expect(violations[0].message).not.toContain('createdAt'); 46 | }); 47 | 48 | it('respects model-wide ignore comments', async () => { 49 | const violations = await run(` 50 | model Products { 51 | /// prisma-lint-ignore-model 52 | id String @id 53 | } 54 | `); 55 | expect(violations.length).toEqual(0); 56 | }); 57 | }); 58 | 59 | describe('relations', () => { 60 | const run = getRunner({ 61 | forAllRelations: true, 62 | }); 63 | 64 | it('returns no violations for indexed relation fields', async () => { 65 | const violations = await run(` 66 | model Foo { 67 | qid String @id 68 | barRef String 69 | bar Bar @relation(fields: [barRef], references: [ref]) 70 | @@index([barRef]) 71 | } 72 | `); 73 | expect(violations.length).toEqual(0); 74 | }); 75 | 76 | it('returns no violations for indexed relation fields with key', async () => { 77 | const violations = await run(` 78 | model Foo { 79 | qid String @id 80 | barRef String 81 | bar Bar @relation(fields: [barRef], references: [ref]) 82 | @@index(fields: [barRef]) 83 | } 84 | `); 85 | expect(violations.length).toEqual(0); 86 | }); 87 | 88 | it('returns violations for non-indexed relation fields', async () => { 89 | const violations = await run(` 90 | model Foo { 91 | qid String @id 92 | barRef String 93 | bar Bar @relation(fields: [barRef], references: [ref]) 94 | } 95 | `); 96 | expect(violations.length).toEqual(1); 97 | }); 98 | }); 99 | 100 | describe('string literal field name', () => { 101 | const run = getRunner({ 102 | forNames: ['tenantQid'], 103 | }); 104 | 105 | describe('with @unique tag', () => { 106 | it('returns no violations', async () => { 107 | const violations = await run(` 108 | model User { 109 | tenantQid String @unique 110 | } 111 | `); 112 | expect(violations.length).toEqual(0); 113 | }); 114 | }); 115 | 116 | describe('with @@index', () => { 117 | it('returns no violations', async () => { 118 | const violations = await run(` 119 | model User { 120 | tenantQid String 121 | @@index(tenantQid) 122 | } 123 | `); 124 | expect(violations.length).toEqual(0); 125 | }); 126 | }); 127 | 128 | describe('with first in compound @@index', () => { 129 | it('returns no violations', async () => { 130 | const violations = await run(` 131 | model User { 132 | tenantQid String 133 | createdAt DateTime 134 | @@index([tenantQid, createdAt]) 135 | } 136 | `); 137 | expect(violations.length).toEqual(0); 138 | }); 139 | }); 140 | 141 | describe('with second in compound @@index', () => { 142 | it('returns violation', async () => { 143 | const violations = await run(` 144 | model Users { 145 | tenantQid String 146 | createdAt DateTime 147 | @@index([createdAt, tenantQid]) 148 | } 149 | `); 150 | expect(violations.length).toEqual(1); 151 | }); 152 | }); 153 | 154 | describe('with no index', () => { 155 | it('returns violation', async () => { 156 | const violations = await run(` 157 | model Users { 158 | tenantQid String 159 | } 160 | `); 161 | expect(violations.length).toEqual(1); 162 | }); 163 | }); 164 | }); 165 | 166 | describe('regex field name', () => { 167 | const run = getRunner({ 168 | forNames: ['/.*[Qq]id$/'], 169 | }); 170 | 171 | describe('with @id tag', () => { 172 | it('returns no violations', async () => { 173 | const violations = await run(` 174 | model User { 175 | qid String @id 176 | } 177 | `); 178 | expect(violations.length).toEqual(0); 179 | }); 180 | }); 181 | 182 | describe('with @unique tag', () => { 183 | it('returns no violations', async () => { 184 | const violations = await run(` 185 | model User { 186 | tenantQid String @unique 187 | } 188 | `); 189 | expect(violations.length).toEqual(0); 190 | }); 191 | }); 192 | 193 | describe('with @@index', () => { 194 | it('returns no violations', async () => { 195 | const violations = await run(` 196 | model User { 197 | tenantQid String 198 | @@index(tenantQid) 199 | } 200 | `); 201 | expect(violations.length).toEqual(0); 202 | }); 203 | }); 204 | 205 | describe('with @@index with key', () => { 206 | it('returns no violations', async () => { 207 | const violations = await run(` 208 | model User { 209 | tenantQid String 210 | @@index(fields: [tenantQid]) 211 | } 212 | `); 213 | expect(violations.length).toEqual(0); 214 | }); 215 | }); 216 | 217 | describe('with first in compound @@index', () => { 218 | it('returns no violations', async () => { 219 | const violations = await run(` 220 | model User { 221 | tenantQid String 222 | createdAt DateTime 223 | @@index([tenantQid, createdAt]) 224 | } 225 | `); 226 | expect(violations.length).toEqual(0); 227 | }); 228 | }); 229 | 230 | describe('with second in compound @@index', () => { 231 | it('returns violation', async () => { 232 | const violations = await run(` 233 | model Users { 234 | tenantQid String 235 | createdAt DateTime 236 | @@index([createdAt, tenantQid]) 237 | } 238 | `); 239 | expect(violations.length).toEqual(1); 240 | }); 241 | }); 242 | 243 | describe('with no index', () => { 244 | it('returns violation', async () => { 245 | const violations = await run(` 246 | model Users { 247 | tenantQid String 248 | } 249 | `); 250 | expect(violations.length).toEqual(1); 251 | }); 252 | }); 253 | }); 254 | }); 255 | -------------------------------------------------------------------------------- /src/rules/require-field-index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Field, 3 | KeyValue, 4 | Model, 5 | BlockAttribute, 6 | Value, 7 | } from '@mrleebo/prisma-ast'; 8 | import { z } from 'zod'; 9 | 10 | import { getRuleIgnoreParams as listRuleIgnoreParams } from '#src/common/ignore.js'; 11 | import { 12 | assertValueIsStringArray, 13 | isKeyValue, 14 | isValue, 15 | listAttributes, 16 | listFields, 17 | } from '#src/common/prisma.js'; 18 | import { toRegExp } from '#src/common/regex.js'; 19 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 20 | 21 | const RULE_NAME = 'require-field-index'; 22 | 23 | const Config = z 24 | .object({ 25 | forAllRelations: z.boolean().optional(), 26 | forNames: z 27 | .union([z.string(), z.array(z.union([z.string(), z.instanceof(RegExp)]))]) 28 | .optional(), 29 | }) 30 | .strict(); 31 | 32 | /** 33 | * Checks that certain fields have indices. 34 | * 35 | * This rule supports selectively ignoring fields via the 36 | * `prisma-lint-ignore-model` comment, like so: 37 | * 38 | * /// prisma-lint-ignore-model require-field-index tenantId 39 | * 40 | * That will ignore only `tenantId` violations for the model. Other 41 | * required indices will still be enforced. A comma-separated list of fields 42 | * can be provided to ignore multiple fields. 43 | * 44 | * @example { forNames: ["createdAt"] } 45 | * // good 46 | * model User { 47 | * createdAt DateTime @unique 48 | * } 49 | * 50 | * model User { 51 | * createdAt DateTime 52 | * @@index([createdAt]) 53 | * } 54 | * 55 | * model User { 56 | * createdAt DateTime 57 | * id String 58 | * @@index([createdAt, id]) 59 | * } 60 | * 61 | * // bad 62 | * model User { 63 | * createdAt string 64 | * } 65 | * 66 | * model User { 67 | * createdAt DateTime 68 | * id String 69 | * @@index([id, createdAt]) 70 | * } 71 | * 72 | * @example { forNames: "/Id$/" ] } 73 | * // good 74 | * model User { 75 | * tenantId String 76 | * @@index([tenantId]) 77 | * } 78 | * 79 | * // bad 80 | * model User { 81 | * tenantId String 82 | * } 83 | * 84 | * @example { forAllRelations: true } 85 | * // good 86 | * type Bar { 87 | * fooId String 88 | * foo Foo @relation(fields: [fooId], references: [id]) 89 | * @@index([fooId]) 90 | * } 91 | * 92 | * // bad 93 | * type Bar { 94 | * fooId String 95 | * foo Foo @relation(fields: [fooId], references: [id]) 96 | * } 97 | */ 98 | export default { 99 | ruleName: RULE_NAME, 100 | configSchema: Config, 101 | create: (config, context) => { 102 | const forAllRelations = config.forAllRelations ?? false; 103 | const forNames = config.forNames ?? []; 104 | const forNamesList = Array.isArray(forNames) ? forNames : [forNames]; 105 | const forNamesRexExpList = forNamesList.map((r) => toRegExp(r)); 106 | // Each file gets its own instance of the rule, so we don't need 107 | // to worry about model name collisions across files. 108 | const indexSetByModelName = new Map(); 109 | const relationSetByModelName = new Map(); 110 | return { 111 | Field: (model, field) => { 112 | const fieldName = field.name; 113 | const modelName = model.name; 114 | 115 | const ruleIgnoreParams = listRuleIgnoreParams(model, RULE_NAME); 116 | const ignoreNameSet = new Set(ruleIgnoreParams); 117 | if (ignoreNameSet.has(fieldName)) { 118 | return; 119 | } 120 | 121 | if (isIdField(field) || isUniqueField(field)) { 122 | return; 123 | } 124 | 125 | const report = () => { 126 | const message = `Field "${fieldName}" must have an index.`; 127 | context.report({ model, field, message }); 128 | }; 129 | 130 | const getIndexSet = () => { 131 | if (!indexSetByModelName.has(modelName)) { 132 | indexSetByModelName.set(modelName, extractIndexSet(model)); 133 | } 134 | const indexSet = indexSetByModelName.get(modelName); 135 | if (!indexSet) { 136 | throw new Error(`Expected index set for ${modelName}.`); 137 | } 138 | return indexSet; 139 | }; 140 | 141 | const matches = forNamesRexExpList.filter((r) => r.test(fieldName)); 142 | if (matches.length > 0) { 143 | const indexSet = getIndexSet(); 144 | if (!indexSet.has(fieldName)) { 145 | report(); 146 | return; 147 | } 148 | } 149 | 150 | if (forAllRelations) { 151 | const indexSet = getIndexSet(); 152 | if (!relationSetByModelName.has(modelName)) { 153 | relationSetByModelName.set(modelName, extractRelationSet(model)); 154 | } 155 | const relationSet = relationSetByModelName.get(modelName); 156 | if (!relationSet) { 157 | throw new Error(`Expected relation set for ${modelName}.`); 158 | } 159 | if (relationSet.has(fieldName) && !indexSet.has(fieldName)) { 160 | report(); 161 | } 162 | } 163 | }, 164 | }; 165 | }, 166 | } satisfies FieldRuleDefinition>; 167 | 168 | type IndexSet = Set; 169 | type RelationSet = Set; 170 | 171 | function extractRelationSet(model: Model): RelationSet { 172 | const fields = listFields(model); 173 | const set = new Set(); 174 | fields.forEach((field) => { 175 | const relations = extractRelationFieldNames(field); 176 | relations.forEach((relation) => { 177 | set.add(relation); 178 | }); 179 | }); 180 | return set; 181 | } 182 | 183 | function extractIndexSet(model: Model): IndexSet { 184 | const modelAttributes = listAttributes(model); 185 | const set = new Set(); 186 | modelAttributes.forEach((value) => { 187 | if (value.name === 'index') { 188 | set.add(extractPrimaryFieldNameFromRelationListAttribute(value)); 189 | } else if (value.name === 'unique') { 190 | set.add(extractPrimaryFieldNameFromRelationListAttribute(value)); 191 | } 192 | }); 193 | return set; 194 | } 195 | 196 | function isIdField(field: Field): boolean { 197 | return Boolean( 198 | field.attributes?.find((attribute) => attribute.name === 'id'), 199 | ); 200 | } 201 | 202 | function isUniqueField(field: Field): boolean { 203 | return Boolean( 204 | field.attributes?.find((attribute) => attribute.name === 'unique'), 205 | ); 206 | } 207 | 208 | function extractPrimaryFieldNameFromRelationListAttribute( 209 | attribute: BlockAttribute, 210 | ): string { 211 | const [arg] = attribute.args; 212 | let value: Value; 213 | if (!isValue(arg.value)) { 214 | // these arguments are describing a complex relation 215 | const fieldsValue: Value | undefined = ( 216 | attribute.args.find(({ value }) => { 217 | if (isKeyValue(value)) { 218 | return value.key === 'fields'; 219 | } 220 | 221 | return false; 222 | })?.value as KeyValue 223 | ).value; 224 | 225 | if (fieldsValue == null) { 226 | throw new Error( 227 | `Failed to parse attribute, could not find fields argument ${JSON.stringify( 228 | attribute, 229 | )}`, 230 | ); 231 | } 232 | 233 | value = fieldsValue; 234 | } else { 235 | value = arg.value; 236 | } 237 | 238 | // @@index(value) or @@unique(value) 239 | if (typeof value === 'string') { 240 | return value; 241 | } 242 | 243 | // @@index([value]) or @@unique([value]) 244 | const [firstFieldValue] = assertValueIsStringArray(value); 245 | if (typeof firstFieldValue === 'string') { 246 | // it should always be a string 247 | return firstFieldValue; 248 | } 249 | 250 | throw new Error('Failed to parse attribute, first value is not a string'); 251 | } 252 | 253 | function extractRelationFieldNames(field: Field): Array { 254 | const relationAttribute = field.attributes?.find( 255 | (attribute) => attribute.name === 'relation', 256 | ); 257 | const fieldsArg = relationAttribute?.args?.find((arg) => { 258 | if (isKeyValue(arg.value)) { 259 | return arg.value.key === 'fields'; 260 | } 261 | 262 | return false; 263 | }); 264 | 265 | if (fieldsArg == null) { 266 | return []; 267 | } 268 | 269 | const fieldsArgValue = (fieldsArg.value as KeyValue).value; 270 | return assertValueIsStringArray(fieldsArgValue); 271 | } 272 | -------------------------------------------------------------------------------- /src/rules/require-field-type.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import requireFieldType from '#src/rules/require-field-type.js'; 4 | 5 | describe('require-field-type', () => { 6 | const getRunner = (config: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'require-field-type': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [requireFieldType], 16 | }); 17 | 18 | describe('string literal field name', () => { 19 | const run = getRunner({ 20 | require: [{ ifName: 'id', type: 'String' }], 21 | }); 22 | 23 | describe('correct type', () => { 24 | it('returns no violations', async () => { 25 | const violations = await run(` 26 | model User { 27 | id String 28 | } 29 | `); 30 | expect(violations.length).toEqual(0); 31 | }); 32 | }); 33 | 34 | describe('incorrect type', () => { 35 | it('returns violation', async () => { 36 | const violations = await run(` 37 | model Users { 38 | id Int 39 | } 40 | `); 41 | expect(violations.length).toEqual(1); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('string literal field name with native type', () => { 47 | const run = getRunner({ 48 | require: [{ ifName: 'id', type: 'String', nativeType: 'Uuid' }], 49 | }); 50 | 51 | describe('correct type and native type', () => { 52 | it('returns no violations', async () => { 53 | const violations = await run(` 54 | model User { 55 | id String @db.Uuid 56 | } 57 | `); 58 | expect(violations.length).toEqual(0); 59 | }); 60 | }); 61 | 62 | describe('incorrect type', () => { 63 | it('returns violation', async () => { 64 | const violations = await run(` 65 | model Users { 66 | id Int 67 | } 68 | `); 69 | expect(violations.length).toEqual(1); 70 | }); 71 | }); 72 | 73 | describe('missing native type', () => { 74 | it('returns violation', async () => { 75 | const violations = await run(` 76 | model Users { 77 | id String 78 | } 79 | `); 80 | expect(violations.length).toEqual(1); 81 | }); 82 | }); 83 | 84 | describe('incorrect native type', () => { 85 | it('returns violation', async () => { 86 | const violations = await run(` 87 | model Users { 88 | id String @db.Oid 89 | } 90 | `); 91 | expect(violations.length).toEqual(1); 92 | }); 93 | }); 94 | }); 95 | 96 | describe('regex field name', () => { 97 | const run = getRunner({ 98 | require: [{ ifName: /At$/, type: 'DateTime' }], 99 | }); 100 | 101 | describe('correct type', () => { 102 | it('returns no violations', async () => { 103 | const violations = await run(` 104 | model User { 105 | createdAt DateTime 106 | } 107 | `); 108 | expect(violations.length).toEqual(0); 109 | }); 110 | }); 111 | 112 | describe('incorrect type', () => { 113 | it('returns violation', async () => { 114 | const violations = await run(` 115 | model Users { 116 | createdAt Int 117 | } 118 | `); 119 | expect(violations.length).toEqual(1); 120 | }); 121 | }); 122 | }); 123 | 124 | describe('regex field name with native type', () => { 125 | const run = getRunner({ 126 | require: [{ ifName: /Id$/, type: 'String', nativeType: 'Uuid' }], 127 | }); 128 | 129 | describe('correct type and native type', () => { 130 | it('returns no violations', async () => { 131 | const violations = await run(` 132 | model User { 133 | userId String @db.Uuid 134 | } 135 | `); 136 | expect(violations.length).toEqual(0); 137 | }); 138 | }); 139 | 140 | describe('incorrect type', () => { 141 | it('returns violation', async () => { 142 | const violations = await run(` 143 | model Users { 144 | userId Int 145 | } 146 | `); 147 | expect(violations.length).toEqual(1); 148 | }); 149 | }); 150 | 151 | describe('missing native type', () => { 152 | it('returns violation', async () => { 153 | const violations = await run(` 154 | model Users { 155 | userId String 156 | } 157 | `); 158 | expect(violations.length).toEqual(1); 159 | }); 160 | }); 161 | 162 | describe('incorrect native type', () => { 163 | it('returns violation', async () => { 164 | const violations = await run(` 165 | model Users { 166 | userId String @db.Oid 167 | } 168 | `); 169 | expect(violations.length).toEqual(1); 170 | }); 171 | }); 172 | }); 173 | 174 | describe('regex string field name', () => { 175 | const run = getRunner({ 176 | require: [{ ifName: '/At$/', type: 'DateTime' }], 177 | }); 178 | 179 | describe('correct type', () => { 180 | it('returns no violations', async () => { 181 | const violations = await run(` 182 | model User { 183 | createdAt DateTime 184 | } 185 | `); 186 | expect(violations.length).toEqual(0); 187 | }); 188 | }); 189 | 190 | describe('incorrect type', () => { 191 | it('returns violation', async () => { 192 | const violations = await run(` 193 | model Users { 194 | createdAt Int 195 | } 196 | `); 197 | expect(violations.length).toEqual(1); 198 | }); 199 | }); 200 | }); 201 | 202 | describe('regex string field name with native type', () => { 203 | const run = getRunner({ 204 | require: [{ ifName: '/Id$/', type: 'String', nativeType: 'Uuid' }], 205 | }); 206 | 207 | describe('correct type and native type', () => { 208 | it('returns no violations', async () => { 209 | const violations = await run(` 210 | model User { 211 | userId String @db.Uuid 212 | } 213 | `); 214 | expect(violations.length).toEqual(0); 215 | }); 216 | }); 217 | 218 | describe('incorrect type', () => { 219 | it('returns violation', async () => { 220 | const violations = await run(` 221 | model Users { 222 | userId Int 223 | } 224 | `); 225 | expect(violations.length).toEqual(1); 226 | }); 227 | }); 228 | 229 | describe('missing native type', () => { 230 | it('returns violation', async () => { 231 | const violations = await run(` 232 | model Users { 233 | userId String 234 | } 235 | `); 236 | expect(violations.length).toEqual(1); 237 | }); 238 | }); 239 | 240 | describe('incorrect native type', () => { 241 | it('returns violation', async () => { 242 | const violations = await run(` 243 | model Users { 244 | userId String @db.Oid 245 | } 246 | `); 247 | expect(violations.length).toEqual(1); 248 | }); 249 | }); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /src/rules/require-field-type.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { toRegExp } from '#src/common/regex.js'; 4 | import type { FieldRuleDefinition } from '#src/common/rule.js'; 5 | 6 | const RULE_NAME = 'require-field-type'; 7 | 8 | const Config = z 9 | .object({ 10 | require: z.array( 11 | z.object({ 12 | ifName: z.union([z.string(), z.instanceof(RegExp)]), 13 | type: z.string(), 14 | nativeType: z.string().optional(), 15 | }), 16 | ), 17 | }) 18 | .strict(); 19 | 20 | /** 21 | * Checks that certain fields have a specific type. 22 | * 23 | * @example { require: [{ ifName: "id", type: "String" }] } 24 | * // good 25 | * model User { 26 | * id String 27 | * } 28 | * 29 | * // bad 30 | * model User { 31 | * id Int 32 | * } 33 | * 34 | * @example { require: [{ ifName: "/At$/", type: "DateTime" }] } 35 | * // good 36 | * model User { 37 | * createdAt DateTime 38 | * updatedAt DateTime 39 | * } 40 | * 41 | * // bad 42 | * model User { 43 | * createdAt String 44 | * updatedAt String 45 | * } 46 | * 47 | * @example { require: [{ ifName: "id", type: "String", nativeType: "Uuid" }] } 48 | * // good 49 | * model User { 50 | * id String @db.Uuid 51 | * } 52 | * 53 | * // bad 54 | * model User { 55 | * id Int 56 | * } 57 | * 58 | * // bad 59 | * model User { 60 | * id String 61 | * } 62 | * 63 | * // bad 64 | * model User { 65 | * id String @db.Oid 66 | * } 67 | */ 68 | export default { 69 | ruleName: RULE_NAME, 70 | configSchema: Config, 71 | create: (config, context) => { 72 | const requireWithRegExp = config.require.map((r) => ({ 73 | ...r, 74 | ifNameRegExp: toRegExp(r.ifName), 75 | })); 76 | return { 77 | Field: (model, field) => { 78 | const matches = requireWithRegExp.filter((r) => 79 | r.ifNameRegExp.test(field.name), 80 | ); 81 | if (matches.length === 0) { 82 | return; 83 | } 84 | const areMatchesConflicting = 85 | new Set(matches.map(({ type }) => type)).size > 1 || 86 | new Set(matches.map(({ nativeType }) => nativeType).filter(Boolean)) 87 | .size > 1; 88 | if (areMatchesConflicting) { 89 | const message = `Field has conflicting type require: ${JSON.stringify( 90 | matches.map(({ ifName, type, nativeType }) => ({ 91 | ifName, 92 | type, 93 | nativeType, 94 | })), 95 | )}.`; 96 | 97 | context.report({ model, field, message }); 98 | } 99 | 100 | const messages = []; 101 | 102 | const actualType = field.fieldType; 103 | const expectedType = matches[0].type; 104 | if (actualType !== expectedType) { 105 | messages.push( 106 | `Field type "${actualType}" does not match expected type "${expectedType}".`, 107 | ); 108 | } 109 | 110 | const expectedNativeType = matches[0].nativeType; 111 | const actualNativeType = field.attributes?.find( 112 | (attr) => attr.group === 'db', 113 | )?.name; 114 | if (expectedNativeType && !actualNativeType) { 115 | messages.push( 116 | `Field native type is not specified, but expected native type "${expectedNativeType}".`, 117 | ); 118 | } else if ( 119 | expectedNativeType && 120 | actualNativeType !== expectedNativeType 121 | ) { 122 | messages.push( 123 | `Field native type "${actualNativeType}" does not match expected native type "${expectedNativeType}".`, 124 | ); 125 | } 126 | 127 | if (messages.length > 0) { 128 | const message = messages.join('\n'); 129 | context.report({ model, field, message }); 130 | } 131 | }, 132 | }; 133 | }, 134 | } satisfies FieldRuleDefinition>; 135 | -------------------------------------------------------------------------------- /src/rules/require-field.test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleConfig } from '#src/common/config.js'; 2 | import { testLintPrismaSource } from '#src/common/test.js'; 3 | import requireField from '#src/rules/require-field.js'; 4 | 5 | describe('require-field', () => { 6 | const getRunner = (config: RuleConfig) => async (sourceCode: string) => 7 | await testLintPrismaSource({ 8 | fileName: 'fake.ts', 9 | sourceCode, 10 | rootConfig: { 11 | rules: { 12 | 'require-field': ['error', config], 13 | }, 14 | }, 15 | ruleDefinitions: [requireField], 16 | }); 17 | 18 | describe('ignore comments', () => { 19 | const run = getRunner({ 20 | require: ['tenantId', 'createdAt', 'revisionCreatedAt'], 21 | }); 22 | 23 | it('respects rule-specific ignore comments', async () => { 24 | const violations = await run(` 25 | model Products { 26 | /// prisma-lint-ignore-model require-field 27 | id String @id 28 | } 29 | `); 30 | expect(violations.length).toEqual(0); 31 | }); 32 | 33 | it('respects field-specific ignore comments with comma', async () => { 34 | const violations = await run(` 35 | model Products { 36 | /// prisma-lint-ignore-model require-field tenantId,createdAt 37 | id String @id 38 | } 39 | `); 40 | expect(violations.length).toEqual(1); 41 | expect(violations[0].message).toContain('revisionCreatedAt'); 42 | expect(violations[0].message).not.toContain('tenantId'); 43 | expect(violations[0].message).not.toContain('createdAt'); 44 | }); 45 | 46 | it('respects model-wide ignore comments', async () => { 47 | const violations = await run(` 48 | model Products { 49 | /// prisma-lint-ignore-model 50 | id String @id 51 | } 52 | `); 53 | expect(violations.length).toEqual(0); 54 | }); 55 | }); 56 | 57 | describe('simple field name', () => { 58 | const run = getRunner({ require: ['tenantId'] }); 59 | 60 | describe('with field', () => { 61 | it('returns no violations', async () => { 62 | const violations = await run(` 63 | model Product { 64 | id String 65 | tenantId String 66 | } 67 | `); 68 | expect(violations.length).toEqual(0); 69 | }); 70 | }); 71 | 72 | describe('without field', () => { 73 | it('returns violation', async () => { 74 | const violations = await run(` 75 | model Product { 76 | id String @id 77 | } 78 | `); 79 | expect(violations.length).toEqual(1); 80 | }); 81 | }); 82 | }); 83 | 84 | describe('conditional ifSibling regex string', () => { 85 | const run = getRunner({ 86 | require: [ 87 | { 88 | ifSibling: '/amountD\\d$/', 89 | name: 'currencyCode', 90 | }, 91 | ], 92 | }); 93 | 94 | describe('with field', () => { 95 | it('returns no violations', async () => { 96 | const violations = await run(` 97 | model Product { 98 | id String 99 | amountD6 Int 100 | currencyCode String 101 | } 102 | `); 103 | expect(violations.length).toEqual(0); 104 | }); 105 | }); 106 | 107 | describe('without field', () => { 108 | describe('with ifSibling', () => { 109 | it('returns violation', async () => { 110 | const violations = await run(` 111 | model Product { 112 | id String 113 | amountD6 Int 114 | } 115 | `); 116 | expect(violations.length).toEqual(1); 117 | }); 118 | }); 119 | 120 | describe('without ifSibling', () => { 121 | it('returns no violations', async () => { 122 | const violations = await run(` 123 | model Product { 124 | id String 125 | } 126 | `); 127 | expect(violations.length).toEqual(0); 128 | }); 129 | }); 130 | }); 131 | }); 132 | 133 | describe('conditional ifSibling regex', () => { 134 | const run = getRunner({ 135 | require: [ 136 | { 137 | ifSibling: /amountD\d$/, 138 | name: 'currencyCode', 139 | }, 140 | ], 141 | }); 142 | 143 | describe('with field', () => { 144 | it('returns no violations', async () => { 145 | const violations = await run(` 146 | model Product { 147 | id String 148 | amountD6 Int 149 | currencyCode String 150 | } 151 | `); 152 | expect(violations.length).toEqual(0); 153 | }); 154 | }); 155 | 156 | describe('without field', () => { 157 | describe('with ifSibling', () => { 158 | it('returns violation', async () => { 159 | const violations = await run(` 160 | model Product { 161 | id String 162 | amountD6 Int 163 | } 164 | `); 165 | expect(violations.length).toEqual(1); 166 | }); 167 | }); 168 | 169 | describe('without ifSibling', () => { 170 | it('returns no violations', async () => { 171 | const violations = await run(` 172 | model Product { 173 | id String 174 | } 175 | `); 176 | expect(violations.length).toEqual(0); 177 | }); 178 | }); 179 | }); 180 | }); 181 | 182 | describe('conditional ifSibling string', () => { 183 | const run = getRunner({ 184 | require: [ 185 | { 186 | ifSibling: 'amountD6', 187 | name: 'currencyCode', 188 | }, 189 | ], 190 | }); 191 | 192 | describe('with field', () => { 193 | it('returns no violations', async () => { 194 | const violations = await run(` 195 | model Product { 196 | id String 197 | amountD6 Int 198 | currencyCode String 199 | } 200 | `); 201 | expect(violations.length).toEqual(0); 202 | }); 203 | }); 204 | 205 | describe('without field', () => { 206 | describe('with ifSibling', () => { 207 | it('returns violation', async () => { 208 | const violations = await run(` 209 | model Product { 210 | id String 211 | amountD6 Int 212 | } 213 | `); 214 | expect(violations.length).toEqual(1); 215 | }); 216 | }); 217 | 218 | describe('without ifSibling', () => { 219 | it('returns no violations', async () => { 220 | const violations = await run(` 221 | model Product { 222 | id String 223 | } 224 | `); 225 | expect(violations.length).toEqual(0); 226 | }); 227 | }); 228 | }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /src/rules/require-field.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { getRuleIgnoreParams } from '#src/common/ignore.js'; 4 | import { listFields } from '#src/common/prisma.js'; 5 | import { isRegexOrRegexStr } from '#src/common/regex.js'; 6 | import type { ModelRuleDefinition } from '#src/common/rule.js'; 7 | 8 | const RULE_NAME = 'require-field'; 9 | 10 | const Config = z 11 | .object({ 12 | require: z.array( 13 | z.union([ 14 | z.string(), 15 | z.object({ 16 | name: z.string(), 17 | ifSibling: z.union([z.string(), z.instanceof(RegExp)]), 18 | }), 19 | ]), 20 | ), 21 | }) 22 | .strict(); 23 | 24 | /** 25 | * Checks that a model has certain fields. 26 | * 27 | * This rule supports selectively ignoring fields via the 28 | * `prisma-lint-ignore-model` comment, like so: 29 | * 30 | * /// prisma-lint-ignore-model require-field tenantId 31 | * 32 | * That will ignore only `tenantId` field violations for the model. Other 33 | * required fields will still be enforced. A comma-separated list of fields 34 | * can be provided to ignore multiple required fields. 35 | * 36 | * @example { require: ["id"] } 37 | * // good 38 | * model User { 39 | * id Int @id 40 | * } 41 | * 42 | * // bad 43 | * model User { 44 | * name String 45 | * } 46 | * 47 | * 48 | * @example { require: [{ name: "currencyCode", ifSibling: "/mountD6$/" }] } 49 | * // good 50 | * model Product { 51 | * currencyCode String 52 | * priceAmountD6 Int 53 | * } 54 | * 55 | * // bad 56 | * model Product { 57 | * priceAmountD6 Int 58 | * } 59 | * 60 | * 61 | */ 62 | export default { 63 | ruleName: RULE_NAME, 64 | configSchema: Config, 65 | create: (config, context) => { 66 | const { require } = config; 67 | const requireNames = require.filter( 68 | (f) => typeof f === 'string', 69 | ) as string[]; 70 | const conditions = require.filter((f) => typeof f === 'object') as { 71 | name: string; 72 | ifSibling: string | RegExp; 73 | }[]; 74 | const simpleIfSiblingConditions = conditions.filter( 75 | (f) => !isRegexOrRegexStr(f.ifSibling), 76 | ) as { name: string; ifSibling: string }[]; 77 | const regexIfSiblingConditions = conditions 78 | .filter((f) => isRegexOrRegexStr(f.ifSibling)) 79 | .map((f) => { 80 | const { ifSibling } = f; 81 | const ifSiblingRegExp = 82 | typeof ifSibling === 'string' 83 | ? new RegExp(ifSibling.slice(1, -1)) 84 | : ifSibling; 85 | return { ...f, ifSiblingRegExp }; 86 | }) as { name: string; ifSiblingRegExp: RegExp }[]; 87 | return { 88 | Model: (model) => { 89 | const ruleIgnoreParams = getRuleIgnoreParams(model, RULE_NAME); 90 | const ignoreNameSet = new Set(ruleIgnoreParams); 91 | 92 | const fields = listFields(model); 93 | const fieldNameSet = new Set(fields.map((f) => f.name)); 94 | 95 | const missingFields = []; 96 | 97 | for (const requireName of requireNames) { 98 | if (ignoreNameSet.has(requireName)) { 99 | continue; 100 | } 101 | if (!fieldNameSet.has(requireName)) { 102 | missingFields.push(requireName); 103 | } 104 | } 105 | 106 | for (const condition of simpleIfSiblingConditions) { 107 | if (ignoreNameSet.has(condition.name)) { 108 | continue; 109 | } 110 | if ( 111 | fieldNameSet.has(condition.ifSibling) && 112 | !fieldNameSet.has(condition.name) 113 | ) { 114 | missingFields.push(condition.name); 115 | } 116 | } 117 | 118 | for (const condition of regexIfSiblingConditions) { 119 | if (ignoreNameSet.has(condition.name)) { 120 | continue; 121 | } 122 | if ( 123 | fields.some((f) => condition.ifSiblingRegExp.test(f.name)) && 124 | !fieldNameSet.has(condition.name) 125 | ) { 126 | missingFields.push(condition.name); 127 | } 128 | } 129 | 130 | if (missingFields.length > 0) { 131 | context.report({ 132 | message: `Missing required fields: ${missingFields 133 | .map((f) => `"${f}"`) 134 | .join(', ')}.`, 135 | model, 136 | }); 137 | } 138 | }, 139 | }; 140 | }, 141 | } satisfies ModelRuleDefinition>; 142 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@tsconfig/esm/tsconfig.json", "@tsconfig/node20/tsconfig"], 3 | "compilerOptions": { 4 | "moduleResolution": "nodenext", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "paths": { 8 | "#src/*": ["./src/*"] 9 | }, 10 | "noImplicitAny": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "verbatimModuleSyntax": false 6 | } 7 | } 8 | --------------------------------------------------------------------------------