├── .babel.cjs.json ├── .babel.mjs.json ├── .changeset ├── README.md └── config.json ├── .github └── workflows │ ├── main.yml │ └── pr.yaml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── README.md ├── deps.rb ├── package.json ├── pnpm-lock.yaml ├── src ├── TaggedScope.ts ├── builders │ ├── drizzle.ts │ ├── drizzle │ │ └── pg.ts │ ├── kysely.ts │ └── kysely │ │ └── pg.ts ├── drivers │ └── pg.ts ├── errors.ts ├── index.ts ├── query.ts └── schema │ ├── kysely.ts │ └── pg.ts ├── test ├── helpers │ ├── index.ts │ ├── it.ts │ ├── json.ts │ ├── layer.ts │ ├── pg.drizzle.dsl.ts │ ├── pg.kysely.dsl.ts │ └── pg.schema.ts ├── migrations │ └── pg │ │ ├── 0000_overjoyed_sandman.sql │ │ ├── 0001_brainy_dexter_bennett.sql │ │ ├── 0002_cultured_red_skull.sql │ │ ├── 0003_medical_shooting_star.sql │ │ └── meta │ │ ├── 0000_snapshot.json │ │ ├── 0001_snapshot.json │ │ ├── 0002_snapshot.json │ │ ├── 0003_snapshot.json │ │ └── _journal.json ├── pg.drizzle.test.ts ├── pg.kysely.test.ts └── pg.test.ts ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.examples.json ├── tsconfig.json ├── tsconfig.madge.json ├── tsconfig.test.json └── vite.config.ts /.babel.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["@effect/babel-plugin"], 4 | ["@babel/transform-modules-commonjs"], 5 | ["annotate-pure-calls"] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.babel.mjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [["@effect/babel-plugin"], ["annotate-pure-calls"]] 3 | } 4 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "pigoz/effect-drizzle" } 6 | ], 7 | "commit": false, 8 | "fixed": [], 9 | "linked": [], 10 | "access": "public", 11 | "baseBranch": "main", 12 | "updateInternalDependencies": "patch", 13 | "ignore": [] 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main Flow 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [16.17.1] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - uses: pnpm/action-setup@v2.0.1 27 | name: Install pnpm 28 | id: pnpm-install 29 | with: 30 | version: 7 31 | run_install: false 32 | - name: Get pnpm store directory 33 | id: pnpm-cache 34 | run: | 35 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 36 | - uses: actions/cache@v3 37 | name: Setup pnpm cache 38 | with: 39 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pnpm-store- 43 | - run: pnpm install 44 | - run: pnpm run build 45 | - run: pnpm run circular 46 | - run: pnpm run test 47 | # - run: pnpm run lint 48 | # - run: pnpm run docs 49 | - name: Create Release Pull Request or Publish 50 | id: changesets 51 | uses: changesets/action@v1 52 | with: 53 | version: pnpm run version 54 | publish: pnpm exec changeset publish 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: PR Flow 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "main" 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [16.17.1] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - uses: pnpm/action-setup@v2.0.1 28 | name: Install pnpm 29 | id: pnpm-install 30 | with: 31 | version: 7 32 | run_install: false 33 | - name: Get pnpm store directory 34 | id: pnpm-cache 35 | run: | 36 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 37 | - uses: actions/cache@v3 38 | name: Setup pnpm cache 39 | with: 40 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 41 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 42 | restore-keys: | 43 | ${{ runner.os }}-pnpm-store- 44 | - run: pnpm install 45 | - run: pnpm run build 46 | - run: pnpm run circular 47 | - run: pnpm run test --coverage 48 | # - run: pnpm run lint 49 | # - run: pnpm run docs 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.tsbuildinfo 3 | node_modules/ 4 | yarn-error.log 5 | .ultra.cache.json 6 | .DS_Store 7 | tmp/ 8 | build/ 9 | dist/ 10 | .cache/ 11 | .direnv/ 12 | .idea/ 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # effect-sql 2 | 3 | ## 0.0.34 4 | 5 | ### Patch Changes 6 | 7 | - [`5c9a229`](https://github.com/pigoz/effect-sql/commit/5c9a22915365109afc870968711bd03a2524dc23) Thanks [@pigoz](https://github.com/pigoz)! - chore: move @effect/\* to peerDependencies 8 | 9 | ## 0.0.33 10 | 11 | ### Patch Changes 12 | 13 | - [`6aa70f1`](https://github.com/pigoz/effect-sql/commit/6aa70f12f21346960aa3e0c40b02c6d141b9c6e9) Thanks [@pigoz](https://github.com/pigoz)! - chore: update effect to latest "framework" version 14 | 15 | ## 0.0.32 16 | 17 | ### Patch Changes 18 | 19 | - [`4b8b530`](https://github.com/pigoz/effect-sql/commit/4b8b53088fbaad4d4696e8893b0aae7ba7085e20) Thanks [@pigoz](https://github.com/pigoz)! - chore(deps): update 20 | 21 | ## 0.0.31 22 | 23 | ### Patch Changes 24 | 25 | - [`e6585ab`](https://github.com/pigoz/effect-sql/commit/e6585abd389fee8cfc77f606928230b7b714060f) Thanks [@pigoz](https://github.com/pigoz)! - refactorings, update deps 26 | 27 | ## 0.0.30 28 | 29 | ### Patch Changes 30 | 31 | - [`6bba7f7`](https://github.com/pigoz/effect-sql/commit/6bba7f7159416ad55a7a809eff938f81e97fe75d) Thanks [@pigoz](https://github.com/pigoz)! - update deps 32 | 33 | ## 0.0.29 34 | 35 | ### Patch Changes 36 | 37 | - [`b2e007a`](https://github.com/pigoz/effect-sql/commit/b2e007a9b4e3b70454378ad8f1be23c1b0f100cd) Thanks [@pigoz](https://github.com/pigoz)! - add transaction isolation level & internal refactor 38 | 39 | ## 0.0.28 40 | 41 | ### Patch Changes 42 | 43 | - [`c4c7fc2`](https://github.com/pigoz/effect-sql/commit/c4c7fc2b643b080e611198c21af8d1699185c7ef) Thanks [@pigoz](https://github.com/pigoz)! - add initial driver abstraction 44 | 45 | ## 0.0.27 46 | 47 | ### Patch Changes 48 | 49 | - [#37](https://github.com/pigoz/effect-sql/pull/37) [`a4d5053`](https://github.com/pigoz/effect-sql/commit/a4d50538f573aae105712742857b6f4bb10239eb) Thanks [@pigoz](https://github.com/pigoz)! - big refactor to make everything optional 50 | 51 | ## 0.0.26 52 | 53 | ### Patch Changes 54 | 55 | - [`b7460db`](https://github.com/pigoz/effect-sql/commit/b7460dbbfb2f4e6abdcb2360700d0ed671cb39cf) Thanks [@pigoz](https://github.com/pigoz)! - feat(kysely): only tables from schema when inferring database 56 | 57 | ## 0.0.25 58 | 59 | ### Patch Changes 60 | 61 | - [`518359b`](https://github.com/pigoz/effect-sql/commit/518359b9cf27f70d1197a562a6dcbb492b04c693) Thanks [@pigoz](https://github.com/pigoz)! - fix(schema): move scope from layer to effect 62 | 63 | ## 0.0.24 64 | 65 | ### Patch Changes 66 | 67 | - [`7cfdff3`](https://github.com/pigoz/effect-sql/commit/7cfdff3a4fbea23d74cf326c2f3c4d9f92c8341e) Thanks [@pigoz](https://github.com/pigoz)! - keep ConnectionPool scope at library level 68 | 69 | ## 0.0.23 70 | 71 | ### Patch Changes 72 | 73 | - [`e2199a7`](https://github.com/pigoz/effect-sql/commit/e2199a712c9f192f86acc79900531559b186d969) Thanks [@pigoz](https://github.com/pigoz)! - fix(drizzle): workaround lacking esm support 74 | 75 | ## 0.0.22 76 | 77 | ### Patch Changes 78 | 79 | - [`29c18e1`](https://github.com/pigoz/effect-sql/commit/29c18e1328184e10a83be2f622d8ef8124985045) Thanks [@pigoz](https://github.com/pigoz)! - update deps, revert strings in tags 80 | 81 | ## 0.0.21 82 | 83 | ### Patch Changes 84 | 85 | - [`ad78e32`](https://github.com/pigoz/effect-sql/commit/ad78e3225deb6df735bda4f20440ecb759169d8f) Thanks [@pigoz](https://github.com/pigoz)! - fix: use strings for tags 86 | 87 | ## 0.0.20 88 | 89 | ### Patch Changes 90 | 91 | - [`ecc2922`](https://github.com/pigoz/effect-sql/commit/ecc29223b1499884c843b8085ebc38569d7279f5) Thanks [@pigoz](https://github.com/pigoz)! - fix: rename files for proper cjs resolution 92 | 93 | ## 0.0.19 94 | 95 | ### Patch Changes 96 | 97 | - [`5293766`](https://github.com/pigoz/effect-sql/commit/5293766396dad9f165643b4c1d8ca072e404675f) Thanks [@pigoz](https://github.com/pigoz)! - a few renames 98 | 99 | ## 0.0.18 100 | 101 | ### Patch Changes 102 | 103 | - [#27](https://github.com/pigoz/effect-sql/pull/27) [`43e95d3`](https://github.com/pigoz/effect-sql/commit/43e95d3dcf36f515d5fea18473c3c049f35467f8) Thanks [@pigoz](https://github.com/pigoz)! - chore(deps): update 104 | 105 | ## 0.0.17 106 | 107 | ### Patch Changes 108 | 109 | - [`341ed57`](https://github.com/pigoz/effect-sql/commit/341ed5757275f7c63080d73ee9a679927d355624) Thanks [@pigoz](https://github.com/pigoz)! - great refactor, allows pluggable query builders 110 | 111 | ## 0.0.16 112 | 113 | ### Patch Changes 114 | 115 | - [#21](https://github.com/pigoz/effect-sql/pull/21) [`c1fe083`](https://github.com/pigoz/effect-sql/commit/c1fe08312d8b836bd27b6147bc79800e604ee328) Thanks [@pigoz](https://github.com/pigoz)! - make camelcase optional, use effect connection pool 116 | 117 | ## 0.0.15 118 | 119 | ### Patch Changes 120 | 121 | - [#19](https://github.com/pigoz/effect-sql/pull/19) [`4dcdfdd`](https://github.com/pigoz/effect-sql/commit/4dcdfddb01684b63628acb484ea0b23fea2bf3a9) Thanks [@pigoz](https://github.com/pigoz)! - kysely and json_agg support, rename to effect-sql 122 | 123 | ## 0.0.14 124 | 125 | ### Patch Changes 126 | 127 | - [#17](https://github.com/pigoz/effect-sql/pull/17) [`fc5b422`](https://github.com/pigoz/effect-sql/commit/fc5b42268da0649eadb2e1383ebf7d9c80ab9417) Thanks [@pigoz](https://github.com/pigoz)! - feat(pg): add databaseName, make effect scoped 128 | 129 | ## 0.0.13 130 | 131 | ### Patch Changes 132 | 133 | - [`25866b6`](https://github.com/pigoz/effect-sql/commit/25866b60ca2b18b97c1dea6cf06962e7a4f94d04) Thanks [@pigoz](https://github.com/pigoz)! - chore(deps): update 134 | 135 | ## 0.0.12 136 | 137 | ### Patch Changes 138 | 139 | - [`058601c`](https://github.com/pigoz/effect-sql/commit/058601c6708d3e0150ad256749e6f9dff106cd52) Thanks [@pigoz](https://github.com/pigoz)! - chore(deps): update 140 | 141 | - [`3bbde85`](https://github.com/pigoz/effect-sql/commit/3bbde8571a252917864ff303d44f4215f89a309b) Thanks [@pigoz](https://github.com/pigoz)! - feat(pg): improve config, transparently handle pool 142 | 143 | ## 0.0.11 144 | 145 | ### Patch Changes 146 | 147 | - [`83a9aa1`](https://github.com/pigoz/effect-sql/commit/83a9aa17a1da6f0311d961fa002cebc5dd493b84) Thanks [@pigoz](https://github.com/pigoz)! - add connect; make transaction not lazy 148 | 149 | - [`b338d07`](https://github.com/pigoz/effect-sql/commit/b338d07629e6bf7652db91b9ca4edfdba4daa6a2) Thanks [@pigoz](https://github.com/pigoz)! - chore(deps): update 150 | 151 | ## 0.0.10 152 | 153 | ### Patch Changes 154 | 155 | - [`d9dcc86`](https://github.com/pigoz/effect-sql/commit/d9dcc862a852db4aeb0a73b94b6f3a40c89a6264) Thanks [@pigoz](https://github.com/pigoz)! - add config, wrap migration code 156 | 157 | - [`8b7e30f`](https://github.com/pigoz/effect-sql/commit/8b7e30fcd8a24d7d31d7aed75c81b024c95d9465) Thanks [@pigoz](https://github.com/pigoz)! - fix(drizzle-orm): work around imports in mjs files 158 | 159 | ## 0.0.9 160 | 161 | ### Patch Changes 162 | 163 | - [`091bc32`](https://github.com/pigoz/effect-sql/commit/091bc32fdeff9ab14d918c47f0d0776f3f56c303) Thanks [@pigoz](https://github.com/pigoz)! - fix: move to global import, remove pointless exports 164 | 165 | ## 0.0.8 166 | 167 | ### Patch Changes 168 | 169 | - [`84fb700`](https://github.com/pigoz/effect-sql/commit/84fb7000f09356a57c56f6a7ad2121835273f7fe) Thanks [@pigoz](https://github.com/pigoz)! - chore(deps): move all to peer 170 | 171 | ## 0.0.7 172 | 173 | ### Patch Changes 174 | 175 | - [`46e5d39`](https://github.com/pigoz/effect-sql/commit/46e5d39e63011ec8e13b820954bd9e70ef3632f4) Thanks [@pigoz](https://github.com/pigoz)! - feat(errors): use shorter names 176 | 177 | ## 0.0.6 178 | 179 | ### Patch Changes 180 | 181 | - [`a8b28ab`](https://github.com/pigoz/effect-sql/commit/a8b28ab8467ba8ef5d3dfece48ce56c7742e7d30) Thanks [@pigoz](https://github.com/pigoz)! - feat(pg): improve types, add runQueryExactlyOne 182 | 183 | ## 0.0.5 184 | 185 | ### Patch Changes 186 | 187 | - [`df6330c`](https://github.com/pigoz/effect-sql/commit/df6330cb843dad70e49aa55ee1824dfa7257fe81) Thanks [@pigoz](https://github.com/pigoz)! - feat: re-export InferModel 188 | 189 | ## 0.0.4 190 | 191 | ### Patch Changes 192 | 193 | - [`dc478d6`](https://github.com/pigoz/effect-sql/commit/dc478d69b0714d7816fae4c3e10d8a370aa24d74) Thanks [@pigoz](https://github.com/pigoz)! - chore(deps): update 194 | 195 | ## 0.0.3 196 | 197 | ### Patch Changes 198 | 199 | - [`333ec90`](https://github.com/pigoz/effect-sql/commit/333ec90f83e2bae051c94891fc86e0725d3d5a9f) Thanks [@pigoz](https://github.com/pigoz)! - fix package.json 200 | 201 | ## 0.0.2 202 | 203 | ### Patch Changes 204 | 205 | - [`410c813`](https://github.com/pigoz/effect-sql/commit/410c813006d7d6d9e295c3199a77f63d68b165f1) Thanks [@pigoz](https://github.com/pigoz)! - fix package.json 206 | 207 | ## 0.0.1 208 | 209 | ### Patch Changes 210 | 211 | - [`823f03f`](https://github.com/pigoz/effect-sql/commit/823f03f3431df88baedb9e58bebe0745044de287) Thanks [@pigoz](https://github.com/pigoz)! - feat(pg): add initial implementation 212 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️ **Note:** 2 | 3 | This library is not being actively worked on anymore. 4 | 5 | I suggest you take a look at https://github.com/tim-smart/sqlfx since it's the de facto "official" library. 6 | 7 | # effect-sql 8 | 9 | Relational Databases with Effect! 10 | 11 | This project aims to become a one stop shop to deal with relational databases in [Effect](https://github.com/Effect-TS). 12 | 13 | It's composed of several decoupled pieces, from which you should be able to pick and choose whatever you want to build a "database prelude" that best fits your application. 14 | 15 | - `effect-sql/query` is a wrapper over Node database drivers. It provides an Effectful interface to operate with raw SQL strings and little overhead. A spiritual alternative to `effect-pg`, `effect-mysql`, and such. It includes: 16 | - Layer to manage the ConnectionPool using Effect's Pool 17 | - Query operators with tagged errors in the failure channel 18 | - DSL for nested transactions (using savepoints!) 19 | - (*Doing*): Driver based abstraction to support multiple database engines (focusing on getting PostgreSQL🐘 right initially) 20 | - (*Planned*): Non pooled connections (i.e. PlanetScale) 21 | - (*Planned*): Improved support for sandboxed database drivers 22 | - (*Planned*): Multiple connection strings for HA 23 | 24 | - (*Optional*) `effect-sql/schema`: TypeScript-first schema declaration based on [Drizzle](https://github.com/drizzle-team/drizzle-orm). Features: 25 | - Infer Kysely database using `effect-sql/schema/kysely`. 26 | - (*Planned*): Derive `@effect/schema` types 27 | - (*Planned*): Factory system with faker or fast check 28 | 29 | - (*Optional*) `effect-sql/builders/*`: Query builders to create typesafe queries and to execute them. They are built on top of `effect-sql/query` 30 | - [Kysely](https://github.com/kysely-org/kysely): "blessed" solution 31 | - [Drizzle](https://github.com/drizzle-team/drizzle-orm): "toy" solution, see [Drizzle as a Query Builder](#drizzle-as-a-query-builder) in this README. 32 | 33 | - (*Planned*) `effect-sql/sql`: tagged template literal to build safer queries 34 | 35 | ### Raw SQL Example (minimal!) 36 | ```typescript 37 | // app.ts 38 | import { 39 | runQuery, 40 | runQueryOne, 41 | runQueryExactlyOne, 42 | ConnectionPool, 43 | ConnectionPoolScopedService, 44 | Driver, 45 | } from "effect-sql/query"; 46 | import { PostgreSqlDriver } from "effect-sql/drivers/pg"; 47 | 48 | const post1 = runQuery(`select * from "posts"`); 49 | // ^ Effect> 50 | 51 | const post2 = runQueryOne(`select * from "posts" where id = 1`); 52 | // ^ Effect 53 | 54 | const post3 = runQueryExactlyOne(`select * from "posts" where id = 1`); 55 | // ^ Effect 56 | 57 | const DriverLive = Layer.succeed( 58 | Driver, 59 | PostgreSqlDriver(), 60 | ); 61 | 62 | const ConnectionPoolLive = Layer.scoped( 63 | ConnectionPool, 64 | ConnectionPoolScopedService(), 65 | ); 66 | 67 | pipe( 68 | post3, 69 | Effect.provideLayer(pipe( 70 | DriverLive, 71 | Layer.provideMerge(ConnectionPoolLive) 72 | )), 73 | Effect.runFork 74 | ); 75 | ``` 76 | 77 | ### Full Example (Schema + Query Builder + Camelization) 78 | 79 | ```typescript 80 | // schema.ts 81 | import { pgTable, serial, text } from "effect-sql/schema/pg" 82 | 83 | const posts = pgTable("posts", { 84 | id: serial("id").primaryKey(), 85 | title: text("title").notNull(), 86 | }); 87 | ``` 88 | 89 | ```typescript 90 | // dsl.ts 91 | import { queryBuilderDsl } from "effect-sql/builders/kysely/pg"; 92 | import { InferDatabase } from "effect-sql/schema/kysely"; 93 | import { Selectable } from "kysely"; 94 | 95 | import * as schema from "./schema.ts"; 96 | 97 | interface Database extends CamelCase> {} 98 | export const db = queryBuilderDsl({ useCamelCaseTransformer: true }); 99 | 100 | export interface Post extends Selectable {} 101 | ``` 102 | 103 | ```typescript 104 | // app.ts 105 | import { 106 | runQuery, 107 | runQueryOne, 108 | runQueryExactlyOne, 109 | KyselyQueryBuilder, 110 | } from "effect-sql/builders/kysely"; 111 | 112 | import { transaction } from "effect-sql/query"; 113 | 114 | import { db } from "./dsl.ts"; 115 | 116 | const post1 = runQuery(db.selectFrom("posts")); 117 | // ^ Effect> 118 | 119 | const post2 = runQueryOne(db.selectFrom("posts")); 120 | // ^ Effect 121 | 122 | const post3 = runQueryExactlyOne(db.selectFrom("posts")); 123 | // ^ Effect 124 | 125 | transaction(Effect.all( 126 | db.insertInto('posts').values({ title: 'Solvet saeclum' }), 127 | transaction(Effect.all( 128 | db.insertInto('posts').values({ title: 'in favilla' }), 129 | db.insertInto('posts').values({ title: 'Teste David cum Sibylla' }), 130 | )), 131 | )) 132 | 133 | import { 134 | ConnectionPool, 135 | ConnectionPoolScopedService, 136 | Driver, 137 | } from "effect-sql/query"; 138 | 139 | import { MigrationLayer } from "effect-sql/schema/pg"; 140 | import { PostgreSqlDriver } from "effect-sql/drivers/pg"; 141 | 142 | const DriverLive = Layer.succeed( 143 | Driver, 144 | PostgreSqlDriver(), 145 | ); 146 | 147 | const ConnectionPoolLive = Layer.scoped( 148 | ConnectionPool, 149 | ConnectionPoolScopedService(), 150 | ); 151 | 152 | const MigrationLive = 153 | MigrationLayer(path.resolve(__dirname, "../migrations/pg")); 154 | 155 | const QueryBuilderLive = Layer.succeed( 156 | KyselyQueryBuilder, 157 | db 158 | ); 159 | 160 | pipe( 161 | post3, 162 | Effect.provideLayer(pipe( 163 | DriverLive, 164 | Layer.provideMerge(ConnectionPoolLive), 165 | Layer.provideMerge(MigrationLive), 166 | Layer.provideMerge(QueryBuilderLive) 167 | )), 168 | Effect.runFork 169 | ) 170 | ``` 171 | 172 | 173 | [Please check the tests for more complete examples!](https://github.com/pigoz/effect-sql/tree/main/test) 174 | 175 | #### Drizzle as a Query Builder 176 | 177 | Using Drizzle as a Query Builder is possible, but currently not recommended as 178 | it doesn't correctly map field names. For example: 179 | 180 | ```typescript 181 | db.select({ cityName: cities.name }).from(cities) 182 | ``` 183 | 184 | Will return `{ name: 'New York' }` instead of the expected `{ cityName: 'New York' }`. 185 | 186 | The reason being, instead of converting the above example to the expected SQL: 187 | 188 | ```sql 189 | select "name" as "cityName" from "cities" 190 | ``` 191 | 192 | Drizzle generates a simplified query to fetch raw arrays from the database, 193 | and uses custom logic to assign the correct field names when it turns those 194 | arrays into JS objects [details here!](https://discord.com/channels/1043890932593987624/1093581666666156043) 195 | 196 | The pluggable query builder feature is there to force the internal 197 | implementation of effect-sql to be as modular as possibile. 198 | -------------------------------------------------------------------------------- /deps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/inline' 4 | 5 | gemfile do 6 | source 'https://rubygems.org' 7 | gem 'http' 8 | gem 'awesome_print' 9 | end 10 | 11 | def fetch(url) 12 | JSON.parse(HTTP.get(url).body.to_s) 13 | end 14 | 15 | namespace = 'peerDependencies' 16 | 17 | latest_tag = fetch('https://api.github.com/repos/effect-ts/effect/releases') 18 | .first 19 | .fetch('tag_name') 20 | 21 | upstream = fetch( 22 | "https://raw.githubusercontent.com/Effect-TS/effect/#{latest_tag}/package.json" 23 | ) 24 | 25 | package_json = File.join(__dir__, 'package.json') 26 | 27 | library = JSON.parse(IO.read(package_json)) 28 | 29 | updates = upstream.fetch('dependencies').slice(*library.fetch(namespace).keys) 30 | 31 | updated_library = { 32 | **library, 33 | namespace => library.fetch(namespace).merge(updates) 34 | } 35 | 36 | IO.write(package_json, JSON.pretty_generate(updated_library)) 37 | 38 | `prettier --write #{package_json}` 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "effect-sql", 3 | "version": "0.0.34", 4 | "description": "Use SQL Databases with Effect", 5 | "publishConfig": { 6 | "access": "public", 7 | "directory": "dist" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/pigoz/effect-sql.git" 12 | }, 13 | "tags": [ 14 | "typescript", 15 | "functional-programming", 16 | "orm" 17 | ], 18 | "scripts": { 19 | "test": "vitest", 20 | "migrate:pg": "drizzle-kit generate:pg --out test/migrations/pg --schema test/helpers/pg.schema.ts", 21 | "version": "changeset version && pnpm install --no-frozen-lockfile", 22 | "release": "pnpm run build && changeset publish", 23 | "circular": "skott --fileExtensions=.ts,.tsx --tsconfig=tsconfig.json --displayMode=raw --showCircularDependencies", 24 | "circular:viz": "skott --fileExtensions=.ts,.tsx --tsconfig=tsconfig.json", 25 | "circular:madge": "madge --ts-config ./tsconfig.madge.json --circular --no-color --no-spinner --warning build/esm", 26 | "clean": "rimraf build tsbuildinfo dist .ultra.cache.json .cache", 27 | "build": "pnpm build-all && pnpm build-pack", 28 | "build-cjs": "babel build/esm --config-file ./.babel.cjs.json --out-dir build/cjs --out-file-extension .js --source-maps", 29 | "build-mjs": "babel build/esm --config-file ./.babel.mjs.json --out-dir build/mjs --out-file-extension .mjs --source-maps", 30 | "build-post": "build-utils pack-v4", 31 | "build-pack": "concurrently \"pnpm build-cjs\" \"pnpm build-mjs\" && pnpm build-post", 32 | "build-all": "tsc -b tsconfig.json", 33 | "build-watch": "tsc -b tsconfig.json --watch", 34 | "tc": "tsc --noEmit" 35 | }, 36 | "author": "Stefano Pigozzi ", 37 | "license": "MIT", 38 | "exports": { 39 | ".": { 40 | "require": "./build/cjs/index.js" 41 | }, 42 | "./*": { 43 | "require": "./build/cjs/*.js" 44 | } 45 | }, 46 | "config": { 47 | "side": [], 48 | "modules": [], 49 | "global": [] 50 | }, 51 | "peerDependencies": { 52 | "@effect/data": "^0.17.6", 53 | "@effect/io": "^0.38.2" 54 | }, 55 | "optionalDependencies": { 56 | "drizzle-orm": "^0.26.5", 57 | "kysely": "^0.25.0", 58 | "pg": "^8.11.0" 59 | }, 60 | "devDependencies": { 61 | "@babel/cli": "^7.22.5", 62 | "@babel/core": "^7.22.5", 63 | "@babel/plugin-transform-modules-commonjs": "^7.22.5", 64 | "@changesets/changelog-github": "^0.4.8", 65 | "@changesets/cli": "^2.26.1", 66 | "@effect-ts/build-utils": "^0.40.7", 67 | "@effect-ts/core": "^0.60.5", 68 | "@effect/babel-plugin": "^0.2.0", 69 | "@types/debug": "^4.1.8", 70 | "@types/glob": "^8.1.0", 71 | "@types/node": "^20.3.1", 72 | "@types/pg": "^8.10.2", 73 | "@vitest/coverage-c8": "^0.32.0", 74 | "babel-plugin-annotate-pure-calls": "^0.4.0", 75 | "concurrently": "^8.2.0", 76 | "cpx": "^1.5.0", 77 | "drizzle-kit": "^0.18.1", 78 | "glob": "^10.2.7", 79 | "madge": "^6.1.0", 80 | "picocolors": "^1.0.0", 81 | "rimraf": "^5.0.1", 82 | "skott": "^0.22.1", 83 | "testcontainers": "^9.9.1", 84 | "typescript": "^5.1.3", 85 | "vite": "^4.3.9", 86 | "vite-tsconfig-paths": "^4.2.0", 87 | "vitest": "^0.32.0" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/TaggedScope.ts: -------------------------------------------------------------------------------- 1 | import { dual } from "@effect/data/Function"; 2 | import * as Scope from "@effect/io/Scope"; 3 | import * as Effect from "@effect/io/Effect"; 4 | import * as Context from "@effect/data/Context"; 5 | 6 | export const Tag = (key?: unknown) => 7 | Context.Tag(key); 8 | 9 | interface Tag 10 | extends Context.Tag {} 11 | 12 | export const scoped = dual< 13 | ( 14 | tag: Tag 15 | ) => (self: Effect.Effect) => Effect.Effect, E, A>, 16 | ( 17 | self: Effect.Effect, 18 | tag: Tag 19 | ) => Effect.Effect, E, A> 20 | >(2, (self, tag) => 21 | Effect.acquireUseRelease( 22 | Scope.make(), 23 | (scope) => Effect.provideService(self, tag, scope), 24 | (scope, exit) => Scope.close(scope, exit) 25 | ) 26 | ); 27 | 28 | export const tag = dual< 29 | ( 30 | tag: Tag 31 | ) => ( 32 | self: Effect.Effect 33 | ) => Effect.Effect | I, E, A>, 34 | ( 35 | self: Effect.Effect, 36 | tag: Tag 37 | ) => Effect.Effect | I, E, A> 38 | >(2, (self, tag) => Effect.flatMap(tag, (scope) => Scope.extend(scope)(self))); 39 | -------------------------------------------------------------------------------- /src/builders/drizzle.ts: -------------------------------------------------------------------------------- 1 | import * as Effect from "@effect/io/Effect"; 2 | 3 | import { 4 | runQuery as runRawQuery, 5 | runQueryOne as runRawQueryOne, 6 | runQueryExactlyOne as runRawQueryExactlyOne, 7 | } from "effect-sql/query"; 8 | 9 | interface Compilable extends Promise { 10 | toSQL(): { sql: string; params: unknown[] }; 11 | } 12 | 13 | type InferResult> = Awaited; 14 | 15 | export function runQuery< 16 | C extends Compilable, 17 | A extends InferResult 18 | >(compilable: C) { 19 | const { sql, params } = compilable.toSQL(); 20 | return runRawQuery(sql, params); 21 | } 22 | 23 | export function runQueryRows< 24 | C extends Compilable, 25 | A extends InferResult 26 | >(compilable: C) { 27 | const { sql, params } = compilable.toSQL(); 28 | return Effect.map(runRawQuery(sql, params), (result) => result.rows); 29 | } 30 | 31 | export function runQueryOne< 32 | C extends Compilable, 33 | A extends InferResult extends (infer X)[] ? X : never 34 | >(compilable: C) { 35 | const { sql, params } = compilable.toSQL(); 36 | return runRawQueryOne(sql, params); 37 | } 38 | 39 | export function runQueryExactlyOne< 40 | C extends Compilable, 41 | A extends InferResult extends (infer X)[] ? X : never 42 | >(compilable: C) { 43 | const { sql, params } = compilable.toSQL(); 44 | return runRawQueryExactlyOne(sql, params); 45 | } 46 | -------------------------------------------------------------------------------- /src/builders/drizzle/pg.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/node-postgres"; 2 | 3 | export function queryBuilderDsl() { 4 | return drizzle(Symbol.for("postgres-stub") as any); 5 | } 6 | -------------------------------------------------------------------------------- /src/builders/kysely.ts: -------------------------------------------------------------------------------- 1 | import * as Effect from "@effect/io/Effect"; 2 | import * as Context from "@effect/data/Context"; 3 | 4 | import { 5 | runQuery as runRawQuery, 6 | runQueryOne as runRawQueryOne, 7 | runQueryExactlyOne as runRawQueryExactlyOne, 8 | } from "effect-sql/query"; 9 | 10 | import { Kysely, QueryResult, Compilable, InferResult } from "kysely"; 11 | import { DatabaseError } from "effect-sql/errors"; 12 | import { pipe } from "@effect/data/Function"; 13 | 14 | export function runQuery< 15 | C extends Compilable, 16 | A extends InferResult[number] 17 | >(compilable: C) { 18 | const { sql, parameters } = compilable.compile(); 19 | return Effect.flatMap(KyselyQueryBuilder, (builder) => 20 | pipe( 21 | runRawQuery(sql, parameters), 22 | Effect.flatMap((_) => builder.afterQueryHook(_)) 23 | ) 24 | ); 25 | } 26 | 27 | export function runQueryRows< 28 | C extends Compilable, 29 | A extends InferResult[number] 30 | >(compilable: C) { 31 | return Effect.map(runQuery(compilable), (result) => result.rows); 32 | } 33 | 34 | export function runQueryOne< 35 | C extends Compilable, 36 | A extends InferResult[number] 37 | >(compilable: C) { 38 | const { sql, parameters } = compilable.compile(); 39 | return Effect.flatMap(KyselyQueryBuilder, (builder) => 40 | pipe( 41 | runRawQueryOne(sql, parameters), 42 | Effect.flatMap((_) => builder.afterQueryHookOne(_)) 43 | ) 44 | ); 45 | } 46 | 47 | export function runQueryExactlyOne< 48 | C extends Compilable, 49 | A extends InferResult[number] 50 | >(compilable: C) { 51 | const { sql, parameters } = compilable.compile(); 52 | return Effect.flatMap(KyselyQueryBuilder, (builder) => 53 | pipe( 54 | runRawQueryExactlyOne(sql, parameters), 55 | Effect.flatMap((_) => builder.afterQueryHookOne(_)) 56 | ) 57 | ); 58 | } 59 | 60 | export interface KyselyQueryBuilder { 61 | readonly _: unique symbol; 62 | } 63 | 64 | export const KyselyQueryBuilder = Context.Tag< 65 | KyselyQueryBuilder, 66 | KyselyEffect 67 | >(Symbol.for("pigoz/effect-sql/KyselyQueryBuilder")); 68 | 69 | export class KyselyEffect extends Kysely { 70 | afterQueryHook( 71 | result: QueryResult 72 | ): Effect.Effect> { 73 | return Effect.tryPromise({ 74 | try: () => this.#transformResult(result), 75 | catch: (err) => this.#databaseError(err), 76 | }); 77 | } 78 | 79 | afterQueryHookOne(result: X): Effect.Effect { 80 | return pipe( 81 | Effect.tryPromise({ 82 | try: () => this.#transformResult({ rows: [result] }), 83 | catch: (err) => this.#databaseError(err), 84 | }), 85 | Effect.map((x) => x.rows[0]!) 86 | ); 87 | } 88 | 89 | #databaseError(err: unknown) { 90 | return new DatabaseError({ 91 | message: 92 | err instanceof Error ? err.message : "generic afterQueryHook error", 93 | }); 94 | } 95 | 96 | async #transformResult(result: QueryResult): Promise> { 97 | // XXX figure out a way to get to the proper queryId 98 | const queryId = { queryId: "unsupported" }; 99 | 100 | for (const plugin of this.getExecutor().plugins) { 101 | result = await plugin.transformResult({ result, queryId }); 102 | } 103 | 104 | return result; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/builders/kysely/pg.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PostgresAdapter, 3 | PostgresQueryCompiler, 4 | PostgresIntrospector, 5 | KyselyConfig, 6 | DummyDriver, 7 | } from "kysely"; 8 | 9 | import { KyselyEffect } from "effect-sql/builders/kysely"; 10 | type Config = Omit; 11 | 12 | export function queryBuilderDsl(config?: Config) { 13 | return new KyselyEffect({ 14 | ...config, 15 | dialect: { 16 | createAdapter: () => new PostgresAdapter(), 17 | createIntrospector: (db) => new PostgresIntrospector(db), 18 | createQueryCompiler: () => new PostgresQueryCompiler(), 19 | createDriver: () => new DummyDriver(), 20 | }, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/drivers/pg.ts: -------------------------------------------------------------------------------- 1 | import * as Effect from "@effect/io/Effect"; 2 | import * as Context from "@effect/data/Context"; 3 | import * as Option from "@effect/data/Option"; 4 | import { DatabaseError } from "effect-sql/errors"; 5 | import pg from "pg"; 6 | import { pipe } from "@effect/data/Function"; 7 | import { 8 | Client, 9 | Driver, 10 | QueryResult, 11 | ClientService, 12 | IsolationLevel, 13 | } from "effect-sql/query"; 14 | 15 | interface PostgreSqlClient extends Client { 16 | native: pg.Client; 17 | } 18 | 19 | const ErrorFromPg = (error?: Error) => 20 | error 21 | ? Effect.fail( 22 | new DatabaseError({ 23 | name: "ConnectionPoolError", 24 | message: error.message, 25 | }) 26 | ) 27 | : Effect.unit; 28 | 29 | function QueryResultFromPg(result: pg.QueryResult): QueryResult { 30 | return { 31 | rowCount: result.rowCount === null ? undefined : BigInt(result.rowCount), 32 | rows: result.rows ?? [], 33 | }; 34 | } 35 | 36 | export function PostgreSqlDriver(): Driver { 37 | const connect = (connectionString: string) => 38 | pipe( 39 | Effect.sync(() => new pg.Client({ connectionString })), 40 | Effect.tap((client) => 41 | Effect.async((resume) => 42 | client.connect((error) => resume(ErrorFromPg(error))) 43 | ) 44 | ), 45 | Effect.map((native) => ClientService({ native, savepoint: 0 }) as C) 46 | ); 47 | 48 | const runQueryImpl = ( 49 | client: C, 50 | sql: string, 51 | parameters: readonly unknown[] 52 | ) => 53 | Effect.async((resume) => { 54 | client.native.query( 55 | { text: sql, values: parameters?.slice(0) }, 56 | (error: pg.DatabaseError, result: pg.QueryResult) => { 57 | if (error) { 58 | resume( 59 | Effect.fail( 60 | new DatabaseError({ 61 | code: error.code, 62 | name: "QueryError", 63 | message: error.message, 64 | }) 65 | ) 66 | ); 67 | } else { 68 | resume(Effect.succeed(QueryResultFromPg(result))); 69 | } 70 | } 71 | ); 72 | }); 73 | 74 | const disconnect = (client: C) => 75 | Effect.async((resume) => 76 | client.native.end((error) => resume(ErrorFromPg(error))) 77 | ); 78 | 79 | return { 80 | _tag: "Driver", 81 | connect, 82 | runQuery: runQueryImpl, 83 | disconnect, 84 | 85 | start: { 86 | transaction: (client) => 87 | Effect.contextWithEffect((r: Context.Context) => 88 | Option.match(Context.getOption(r, IsolationLevel), { 89 | onNone: () => runQueryImpl(client, `start transaction`, []), 90 | onSome: (isolation) => 91 | runQueryImpl( 92 | client, 93 | `start transaction isolation level ${isolation.sql}`, 94 | [] 95 | ), 96 | }) 97 | ), 98 | savepoint: (client, name) => 99 | runQueryImpl(client, `savepoint ${name}`, []), 100 | }, 101 | 102 | rollback: { 103 | transaction: (client) => runQueryImpl(client, `rollback`, []), 104 | savepoint: (client, name) => 105 | runQueryImpl(client, `rollback to ${name}`, []), 106 | }, 107 | 108 | commit: { 109 | transaction: (client) => runQueryImpl(client, `commit`, []), 110 | savepoint: (client, name) => 111 | runQueryImpl(client, `release savepoint ${name}`, []), 112 | }, 113 | }; 114 | } 115 | 116 | export function PostgreSqlSandboxedDriver< 117 | C extends PostgreSqlClient 118 | >(): Driver { 119 | const driver = PostgreSqlDriver(); 120 | driver.commit.transaction = driver.rollback.transaction; 121 | return driver; 122 | } 123 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import * as Data from "@effect/data/Data"; 2 | 3 | const DatabaseErrorSymbolKey = "pigoz/effect-sql/DatabaseError"; 4 | const DatabaseErrorTypeId: unique symbol = Symbol.for(DatabaseErrorSymbolKey); 5 | type DatabaseErrorTypeId = typeof DatabaseErrorTypeId; 6 | 7 | export class DatabaseError extends Data.TaggedClass("DatabaseError")<{ 8 | readonly code?: string; 9 | readonly name?: string; 10 | readonly message: string; 11 | }> { 12 | readonly [DatabaseErrorTypeId] = DatabaseErrorTypeId; 13 | } 14 | 15 | const MigrationErrorTypeId: unique symbol = Symbol.for( 16 | "pigoz/effect-sql/MigrationError" 17 | ); 18 | 19 | type MigrationErrorTypeId = typeof MigrationErrorTypeId; 20 | 21 | export class MigrationError extends Data.TaggedClass("MigrationError")<{ 22 | readonly error: unknown; 23 | }> { 24 | readonly [MigrationErrorTypeId] = MigrationErrorTypeId; 25 | } 26 | 27 | export const isDatabaseError = (u: unknown): u is DatabaseError => 28 | typeof u === "object" && u != null && DatabaseErrorTypeId in u; 29 | 30 | const NotFoundSymbolKey = "pigoz/effect-sql/NotFound"; 31 | const NotFoundTypeId: unique symbol = Symbol.for(NotFoundSymbolKey); 32 | type NotFoundTypeId = typeof NotFoundTypeId; 33 | 34 | export class NotFound extends Data.TaggedClass("NotFound")<{ 35 | readonly sql: string; 36 | readonly parameters: readonly unknown[]; 37 | }> { 38 | readonly [NotFoundTypeId] = NotFoundTypeId; 39 | } 40 | 41 | export const isNotFound = (u: unknown): u is NotFound => 42 | typeof u === "object" && u != null && NotFoundTypeId in u; 43 | 44 | const TooManySymbolKey = "pigoz/effect-sql/TooMany"; 45 | const TooManyTypeId: unique symbol = Symbol.for(TooManySymbolKey); 46 | type TooManyTypeId = typeof TooManyTypeId; 47 | 48 | export class TooMany extends Data.TaggedClass("TooMany")<{ 49 | readonly sql: string; 50 | readonly parameters: readonly unknown[]; 51 | }> { 52 | readonly [TooManyTypeId] = TooManyTypeId; 53 | } 54 | 55 | export const isTooMany = (u: unknown): u is TooMany => 56 | typeof u === "object" && u != null && TooManyTypeId in u; 57 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import { identity, pipe } from "@effect/data/Function"; 2 | import * as Effect from "@effect/io/Effect"; 3 | import * as Data from "@effect/data/Data"; 4 | import * as Exit from "@effect/io/Exit"; 5 | import * as Context from "@effect/data/Context"; 6 | import * as REA from "@effect/data/ReadonlyArray"; 7 | import * as Scope from "@effect/io/Scope"; 8 | import * as Option from "@effect/data/Option"; 9 | import * as Duration from "@effect/data/Duration"; 10 | import * as Config from "@effect/io/Config"; 11 | import * as ConfigSecret from "@effect/io/Config/Secret"; 12 | import * as Pool from "@effect/io/Pool"; 13 | import { ConfigError } from "@effect/io/Config/Error"; 14 | import * as TaggedScope from "effect-sql/TaggedScope"; 15 | import { DatabaseError, NotFound, TooMany } from "effect-sql/errors"; 16 | 17 | // General types 18 | export type UnknownRow = { 19 | [x: string]: unknown; 20 | }; 21 | 22 | export interface QueryResult { 23 | rowCount?: bigint; 24 | rows: T[]; 25 | } 26 | 27 | // Connection Pool Types 28 | export interface Client extends Data.Case { 29 | _tag: "Client"; 30 | native: unknown; 31 | savepoint: number; 32 | } 33 | 34 | const Client = Context.Tag(Symbol.for("pigoz/effect-sql/Client")); 35 | 36 | export interface ConnectionScope extends Data.Case { 37 | _tag: "ConnectionScope"; 38 | } 39 | 40 | export const ConnectionScope = TaggedScope.Tag( 41 | Symbol.for("pigoz/effect-sql/ConnectionScope") 42 | ); 43 | 44 | export const ClientService = Data.tagged("Client"); 45 | 46 | export interface ConnectionPool extends Data.Case { 47 | _tag: "ConnectionPool"; 48 | pool: Pool.Pool; 49 | } 50 | 51 | export const ConnectionPool = Context.Tag( 52 | Symbol.for("pigoz/effect-sql/ConnectionPool") 53 | ); 54 | 55 | const ConnectionPoolService = Data.tagged("ConnectionPool"); 56 | 57 | // Driver 58 | export interface IsolationLevel extends Data.Case { 59 | _tag: "IsolationLevel"; 60 | sql: string; 61 | } 62 | 63 | export const IsolationLevel = Context.Tag( 64 | Symbol.for("pigoz/effect-sql/IsolationLevel") 65 | ); 66 | 67 | export const IsolationLevelService = (sql: string) => 68 | Data.tagged("IsolationLevel")({ sql }); 69 | 70 | export const ReadUncommitted = IsolationLevelService("read uncommitted"); 71 | export const ReadCommitted = IsolationLevelService("read committed"); 72 | export const RepeatableRead = IsolationLevelService("repeatable read"); 73 | export const Serializable = IsolationLevelService("serializable"); 74 | 75 | type DriverQuery = Effect.Effect; 76 | 77 | export interface Driver { 78 | _tag: "Driver"; 79 | 80 | connect(connectionString: string): Effect.Effect; 81 | disconnect(client: C): Effect.Effect; 82 | 83 | runQuery(client: C, sql: string, params: readonly unknown[]): DriverQuery; 84 | 85 | start: { 86 | savepoint(client: C, name: string): DriverQuery; 87 | transaction(client: C): DriverQuery; 88 | }; 89 | 90 | rollback: { 91 | savepoint(client: C, name: string): DriverQuery; 92 | transaction(client: C): DriverQuery; 93 | }; 94 | 95 | commit: { 96 | savepoint(client: C, name: string): DriverQuery; 97 | transaction(client: C): DriverQuery; 98 | }; 99 | } 100 | 101 | export const Driver = Context.Tag( 102 | Symbol.for("pigoz/effect-sql/Driver") 103 | ); 104 | 105 | const defaultConfig = { 106 | databaseUrl: Config.secret("DATABASE_URL"), 107 | // overrides the one in the URL, useful to send out of bound commands like 108 | // drop database by connecting to i.e. template1 109 | databaseName: pipe( 110 | Config.string("DATABASE_NAME"), 111 | Config.option, 112 | Config.withDefault(Option.none()) 113 | ), 114 | }; 115 | 116 | type DatabaseConfig = typeof defaultConfig; 117 | 118 | export function ConnectionPoolScopedService( 119 | config: Partial 120 | ): Effect.Effect { 121 | const getConnectionString = pipe( 122 | Effect.config(Config.all({ ...defaultConfig, ...config })), 123 | Effect.map(({ databaseUrl, databaseName }) => 124 | Option.match(databaseName, { 125 | onNone: () => ConfigSecret.value(databaseUrl), 126 | onSome: (databaseName) => { 127 | const uri = new URL(ConfigSecret.value(databaseUrl)); 128 | uri.pathname = databaseName; 129 | return uri.toString(); 130 | }, 131 | }) 132 | ) 133 | ); 134 | 135 | const createConnectionPool = (connectionString: string) => { 136 | const get = Effect.flatMap(Driver, (driver) => 137 | Effect.acquireRelease(driver.connect(connectionString), (client) => 138 | Effect.orDie(driver.disconnect(client)) 139 | ) 140 | ); 141 | 142 | return Pool.makeWithTTL({ 143 | acquire: get, 144 | min: 1, 145 | max: 20, 146 | timeToLive: Duration.seconds(60), 147 | }); 148 | }; 149 | 150 | return pipe( 151 | getConnectionString, 152 | Effect.flatMap(createConnectionPool), 153 | Effect.map((pool) => ConnectionPoolService({ pool })) 154 | ); 155 | } 156 | 157 | export function connect( 158 | onExistingMapper: (client: Client) => Client = identity 159 | ): Effect.Effect { 160 | return Effect.serviceOption(Client).pipe( 161 | Effect.flatten, 162 | Effect.matchEffect({ 163 | onFailure: () => 164 | Effect.flatMap(ConnectionPool, (service) => 165 | TaggedScope.tag(Pool.get(service.pool), ConnectionScope) 166 | ), 167 | onSuccess: (client) => Effect.succeed(onExistingMapper(client)), 168 | }) 169 | ); 170 | } 171 | 172 | export function connected( 173 | self: Effect.Effect 174 | ): Effect.Effect | ConnectionPool, DatabaseError | E, A> { 175 | return pipe( 176 | connect(), 177 | Effect.flatMap((client) => Effect.provideService(self, Client, client)), 178 | TaggedScope.scoped(ConnectionScope) 179 | ); 180 | } 181 | 182 | export function runQuery( 183 | sql: string, 184 | parameters?: readonly unknown[] 185 | ): Effect.Effect> { 186 | return pipe( 187 | Effect.all({ client: Client, driver: Driver }), 188 | Effect.flatMap(({ client, driver }) => 189 | driver.runQuery(client, sql, parameters ?? []) 190 | ), 191 | connected, 192 | Effect.map((x) => x as QueryResult) 193 | ); 194 | } 195 | 196 | export function runQueryOne( 197 | sql: string, 198 | parameters?: readonly unknown[] 199 | ): Effect.Effect { 200 | return runQuery(sql, parameters).pipe( 201 | Effect.flatMap((result) => 202 | Effect.mapError( 203 | REA.head(result.rows), 204 | () => new NotFound({ sql, parameters: parameters ?? [] }) 205 | ) 206 | ) 207 | ); 208 | } 209 | 210 | export function runQueryExactlyOne( 211 | sql: string, 212 | parameters?: readonly unknown[] 213 | ): Effect.Effect< 214 | ConnectionPool | Driver, 215 | DatabaseError | NotFound | TooMany, 216 | A 217 | > { 218 | return pipe( 219 | runQuery(sql, parameters), 220 | Effect.flatMap( 221 | Effect.unifiedFn((result) => { 222 | const [head, ...rest] = result.rows; 223 | 224 | if (rest.length > 0) { 225 | return Effect.fail( 226 | new TooMany({ sql, parameters: parameters ?? [] }) 227 | ); 228 | } 229 | 230 | return Effect.mapError( 231 | Option.fromNullable(head), 232 | () => new NotFound({ sql, parameters: parameters ?? [] }) 233 | ); 234 | }) 235 | ) 236 | ); 237 | } 238 | 239 | const matchSavepoint = ( 240 | fn: (driver: Driver) => { 241 | savepoint: (client: Client, name: string) => DriverQuery; 242 | transaction: (client: Client) => DriverQuery; 243 | } 244 | ) => 245 | Effect.flatMap( 246 | Effect.all({ client: Client, driver: Driver }), 247 | ({ client, driver }) => { 248 | const implementation = fn(driver); 249 | return client.savepoint > 0 250 | ? implementation.savepoint(client, `savepoint_${client.savepoint}`) 251 | : implementation.transaction(client); 252 | } 253 | ); 254 | 255 | export function transaction( 256 | self: Effect.Effect 257 | ): Effect.Effect< 258 | ConnectionPool | Driver | Exclude, ConnectionScope>, 259 | DatabaseError | E1, 260 | A 261 | > { 262 | const start = matchSavepoint((driver) => driver.start); 263 | const rollback = matchSavepoint((driver) => driver.rollback); 264 | const commit = matchSavepoint((driver) => driver.commit); 265 | 266 | const bumpSavepoint = (c: Client) => 267 | ClientService({ ...c, savepoint: c.savepoint + 1 }); 268 | 269 | const acquire = pipe( 270 | connect(bumpSavepoint), 271 | Effect.flatMap((client) => 272 | Effect.zipRight( 273 | Effect.provideService(start, Client, client), 274 | Effect.succeed(client) 275 | ) 276 | ) 277 | ); 278 | 279 | const use = (client: Client) => Effect.provideService(Client, client)(self); 280 | 281 | const release = (client: Client, exit: Exit.Exit) => 282 | pipe( 283 | exit, 284 | Exit.match({ 285 | onFailure: () => rollback, 286 | onSuccess: () => commit, 287 | }), 288 | Effect.orDie, // XXX handle error when rolling back? 289 | Effect.provideService(Client, client) 290 | ); 291 | 292 | return TaggedScope.scoped( 293 | Effect.acquireUseRelease(acquire, use, release), 294 | ConnectionScope 295 | ); 296 | } 297 | -------------------------------------------------------------------------------- /src/schema/kysely.ts: -------------------------------------------------------------------------------- 1 | import { Table } from "drizzle-orm"; 2 | import { Kyselify } from "drizzle-orm/kysely"; 3 | 4 | type CamelCaseString = 5 | S extends `${infer P1}_${infer P2}${infer P3}` 6 | ? `${Lowercase}${Uppercase}${CamelCaseString}` 7 | : Lowercase; 8 | 9 | type ColumnsToCamelCase = { 10 | [K in keyof T as CamelCaseString]: T[K]; 11 | }; 12 | 13 | export type InferDatabase> = { 14 | [K in keyof T as T[K] extends Table ? K : never]: T[K] extends Table 15 | ? Kyselify 16 | : never; 17 | }; 18 | 19 | export type CamelCaseDatabase> = { 20 | [K in keyof D]: ColumnsToCamelCase; 21 | }; 22 | -------------------------------------------------------------------------------- /src/schema/pg.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "@effect/data/Function"; 2 | import * as Layer from "@effect/io/Layer"; 3 | import * as Effect from "@effect/io/Effect"; 4 | 5 | import { ConnectionScope, connect } from "effect-sql/query"; 6 | import * as TaggedScope from "effect-sql/TaggedScope"; 7 | import { MigrationError } from "effect-sql/errors"; 8 | 9 | import { drizzle, NodePgClient } from "drizzle-orm/node-postgres"; 10 | import { migrate as dmigrate } from "drizzle-orm/node-postgres/migrator"; 11 | 12 | export * from "drizzle-orm/pg-core"; 13 | 14 | export { InferModel } from "drizzle-orm"; 15 | 16 | export function MigrationLayer(path: string) { 17 | return Layer.effectDiscard(migrate(path)); 18 | } 19 | 20 | export function migrate(migrationsFolder: string) { 21 | return pipe( 22 | connect(), 23 | Effect.flatMap((client) => 24 | Effect.tryPromise({ 25 | try: () => { 26 | // XXX figure out how to remove the cast 27 | const d = drizzle(client.native as NodePgClient); 28 | return dmigrate(d, { migrationsFolder }); 29 | }, 30 | catch: (error) => new MigrationError({ error }), 31 | }) 32 | ), 33 | TaggedScope.scoped(ConnectionScope) 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /test/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "vitest"; 2 | export * as it from "./it"; 3 | -------------------------------------------------------------------------------- /test/helpers/it.ts: -------------------------------------------------------------------------------- 1 | import * as V from "vitest"; 2 | import * as Effect from "@effect/io/Effect"; 3 | import { TestLayer, runTestPromise } from "./layer"; 4 | import { transaction } from "effect-sql/query"; 5 | 6 | export type API = V.TestAPI<{}>; 7 | 8 | const it: API = V.it; 9 | 10 | export const effect = (() => { 11 | const f = ( 12 | name: string, 13 | self: () => Effect.Effect, 14 | timeout = 5_000 15 | ) => { 16 | return it(name, () => runTestPromise(Effect.suspend(self)), timeout); 17 | }; 18 | return Object.assign(f, { 19 | skip: ( 20 | name: string, 21 | self: () => Effect.Effect, 22 | timeout = 5_000 23 | ) => { 24 | return it.skip(name, () => runTestPromise(Effect.suspend(self)), timeout); 25 | }, 26 | }); 27 | })(); 28 | 29 | export const sandbox = (() => { 30 | const f = ( 31 | name: string, 32 | self: () => Effect.Effect, 33 | timeout = 5_000 34 | ) => { 35 | return it( 36 | name, 37 | () => runTestPromise(transaction(Effect.suspend(self))), 38 | timeout 39 | ); 40 | }; 41 | return Object.assign(f, { 42 | skip: ( 43 | name: string, 44 | self: () => Effect.Effect, 45 | timeout = 5_000 46 | ) => { 47 | return it.skip( 48 | name, 49 | () => runTestPromise(transaction(Effect.suspend(self))), 50 | timeout 51 | ); 52 | }, 53 | only: ( 54 | name: string, 55 | self: () => Effect.Effect, 56 | timeout = 5_000 57 | ) => { 58 | return it.only( 59 | name, 60 | () => runTestPromise(transaction(Effect.suspend(self))), 61 | timeout 62 | ); 63 | }, 64 | fails: ( 65 | name: string, 66 | self: () => Effect.Effect, 67 | timeout = 5_000 68 | ) => { 69 | return it.fails( 70 | name, 71 | () => runTestPromise(transaction(Effect.suspend(self))), 72 | timeout 73 | ); 74 | }, 75 | }); 76 | })(); 77 | -------------------------------------------------------------------------------- /test/helpers/json.ts: -------------------------------------------------------------------------------- 1 | import { sql, Expression, RawBuilder, Simplify } from "kysely"; 2 | 3 | export function jsonAgg(expr: Expression): RawBuilder[]> { 4 | return sql`(select coalesce(json_agg(agg), '[]') from ${expr} as agg)`; 5 | } 6 | 7 | export function jsonObject(expr: Expression): RawBuilder> { 8 | return sql`(select to_json(obj) from ${expr} as obj)`; 9 | } 10 | -------------------------------------------------------------------------------- /test/helpers/layer.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "@effect/data/Function"; 2 | import * as Effect from "@effect/io/Effect"; 3 | import * as Layer from "@effect/io/Layer"; 4 | import * as Runtime from "@effect/io/Runtime"; 5 | import * as Scope from "@effect/io/Scope"; 6 | import * as Exit from "@effect/io/Exit"; 7 | import * as Context from "@effect/data/Context"; 8 | 9 | import { PostgreSqlContainer } from "testcontainers"; 10 | import * as path from "path"; 11 | import { 12 | ConnectionPool, 13 | ConnectionPoolScopedService, 14 | Driver, 15 | } from "effect-sql/query"; 16 | import * as Config from "@effect/io/Config"; 17 | import * as ConfigSecret from "@effect/io/Config/Secret"; 18 | import { MigrationLayer } from "effect-sql/schema/pg"; 19 | 20 | import { afterAll, beforeAll } from "vitest"; 21 | import { DatabaseError, MigrationError } from "effect-sql/errors"; 22 | import { ConfigError } from "@effect/io/Config/Error"; 23 | import { PostgreSqlSandboxedDriver } from "effect-sql/drivers/pg"; 24 | import { KyselyQueryBuilder } from "effect-sql/builders/kysely"; 25 | 26 | export const testContainer = pipe( 27 | Effect.promise(async () => { 28 | const container = await new PostgreSqlContainer() 29 | .withUsername("postgres") 30 | .withPassword("postgres") 31 | .withDatabase("effect_drizzle_test") 32 | .withReuse() 33 | .start(); 34 | 35 | return container.getConnectionUri() + "?sslmode=disable"; 36 | }) 37 | ); 38 | 39 | export const testLayer = pipe( 40 | Layer.succeed(Driver, PostgreSqlSandboxedDriver()), 41 | Layer.provideMerge( 42 | Layer.scoped( 43 | ConnectionPool, 44 | Effect.flatMap(testContainer, (uri) => 45 | ConnectionPoolScopedService({ 46 | databaseUrl: Config.succeed(ConfigSecret.fromString(uri)), 47 | }) 48 | ) 49 | ) 50 | ), 51 | Layer.provideMerge( 52 | MigrationLayer(path.resolve(__dirname, "../migrations/pg")) 53 | ) 54 | ); 55 | 56 | export type TestLayer = ConnectionPool | Driver | KyselyQueryBuilder; 57 | 58 | const makeRuntime = (layer: Layer.Layer) => 59 | Effect.gen(function* ($) { 60 | const scope = yield* $(Scope.make()); 61 | const ctx: Context.Context = yield* $( 62 | Layer.buildWithScope(scope)(layer) 63 | ); 64 | 65 | const runtime = yield* $(Effect.provideContext(Effect.runtime(), ctx)); 66 | 67 | return { 68 | runtime, 69 | close: Scope.close(scope, Exit.unit), 70 | }; 71 | }); 72 | 73 | export function runTestPromise( 74 | self: Effect.Effect 75 | ) { 76 | const r = (globalThis as any).runtime as Runtime.Runtime; 77 | return Runtime.runPromise(r)(self); 78 | } 79 | 80 | const TIMEOUT = 30000; 81 | 82 | export function usingLayer( 83 | layer: Layer.Layer< 84 | never, 85 | DatabaseError | ConfigError | MigrationError, 86 | ConnectionPool 87 | > 88 | ) { 89 | beforeAll( 90 | async () => 91 | Effect.runPromise(makeRuntime(layer)).then(({ runtime, close }) => { 92 | (globalThis as any).runtime = runtime; 93 | (globalThis as any).close = close; 94 | }), 95 | TIMEOUT 96 | ); 97 | 98 | afterAll(async () => Effect.runPromise((globalThis as any).close), TIMEOUT); 99 | } 100 | -------------------------------------------------------------------------------- /test/helpers/pg.drizzle.dsl.ts: -------------------------------------------------------------------------------- 1 | // Drizzle 2 | import { InferModel } from "drizzle-orm"; 3 | import { queryBuilderDsl } from "effect-sql/builders/drizzle/pg"; 4 | import { cities, users } from "./pg.schema"; 5 | 6 | export const db = queryBuilderDsl(); 7 | export interface City extends InferModel {} 8 | export interface User extends InferModel {} 9 | -------------------------------------------------------------------------------- /test/helpers/pg.kysely.dsl.ts: -------------------------------------------------------------------------------- 1 | import * as schema from "./pg.schema"; 2 | 3 | import { queryBuilderDsl } from "effect-sql/builders/kysely/pg"; 4 | import { InferDatabase, CamelCaseDatabase } from "effect-sql/schema/kysely"; 5 | 6 | import { CamelCasePlugin, Selectable } from "kysely"; 7 | 8 | interface Database extends CamelCaseDatabase> {} 9 | 10 | export const db = queryBuilderDsl({ 11 | plugins: [new CamelCasePlugin()], 12 | }); 13 | 14 | export interface City extends Selectable {} 15 | export interface User extends Selectable {} 16 | -------------------------------------------------------------------------------- /test/helpers/pg.schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | integer, 3 | pgEnum, 4 | pgTable, 5 | serial, 6 | text, 7 | timestamp, 8 | varchar, 9 | } from "effect-sql/schema/pg"; 10 | 11 | export const role = pgEnum("role", ["admin", "user"]); 12 | 13 | export const users = pgTable("users", { 14 | id: serial("id").primaryKey(), 15 | full_name: text("full_name").notNull(), 16 | phone: varchar("phone", { length: 20 }).notNull(), 17 | role: text("role").default("user").notNull(), 18 | city_id: integer("city_id").references(() => cities.id), 19 | created_at: timestamp("created_at").defaultNow().notNull(), 20 | updated_at: timestamp("updated_at").defaultNow().notNull(), 21 | }); 22 | 23 | export const visits = pgTable("visits", { 24 | id: serial("id").primaryKey(), 25 | value: integer("value").notNull(), 26 | city_id: integer("city_id") 27 | .references(() => cities.id) 28 | .notNull(), 29 | user_id: integer("user_id") 30 | .references(() => users.id) 31 | .notNull(), 32 | }); 33 | 34 | export const cities = pgTable("cities", { 35 | id: serial("id").primaryKey(), 36 | name: text("name").notNull(), 37 | created_at: timestamp("created_at").defaultNow().notNull(), 38 | updated_at: timestamp("updated_at").defaultNow().notNull(), 39 | }); 40 | -------------------------------------------------------------------------------- /test/migrations/pg/0000_overjoyed_sandman.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "cities" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "name" text NOT NULL 4 | ); 5 | 6 | CREATE TABLE IF NOT EXISTS "users" ( 7 | "id" serial PRIMARY KEY NOT NULL, 8 | "full_name" text NOT NULL, 9 | "phone" varchar(20) NOT NULL, 10 | "role" text DEFAULT 'user' NOT NULL, 11 | "city_id" integer, 12 | "created_at" timestamp DEFAULT now() NOT NULL, 13 | "updated_at" timestamp DEFAULT now() NOT NULL 14 | ); 15 | 16 | DO $$ BEGIN 17 | ALTER TABLE users ADD CONSTRAINT users_city_id_cities_id_fk FOREIGN KEY ("city_id") REFERENCES cities("id") ON DELETE no action ON UPDATE no action; 18 | EXCEPTION 19 | WHEN duplicate_object THEN null; 20 | END $$; 21 | -------------------------------------------------------------------------------- /test/migrations/pg/0001_brainy_dexter_bennett.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "visits" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "value" integer NOT NULL, 4 | "city_id" integer NOT NULL, 5 | "user_id" integer NOT NULL 6 | ); 7 | 8 | DO $$ BEGIN 9 | ALTER TABLE visits ADD CONSTRAINT visits_city_id_cities_id_fk FOREIGN KEY ("city_id") REFERENCES cities("id") ON DELETE no action ON UPDATE no action; 10 | EXCEPTION 11 | WHEN duplicate_object THEN null; 12 | END $$; 13 | 14 | DO $$ BEGIN 15 | ALTER TABLE visits ADD CONSTRAINT visits_user_id_users_id_fk FOREIGN KEY ("user_id") REFERENCES users("id") ON DELETE no action ON UPDATE no action; 16 | EXCEPTION 17 | WHEN duplicate_object THEN null; 18 | END $$; 19 | -------------------------------------------------------------------------------- /test/migrations/pg/0002_cultured_red_skull.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "role" AS ENUM('admin', 'user'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | -------------------------------------------------------------------------------- /test/migrations/pg/0003_medical_shooting_star.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "cities" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL; 2 | ALTER TABLE "cities" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL; -------------------------------------------------------------------------------- /test/migrations/pg/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "8953f9ad-673e-45a2-b02d-9fab8beeb159", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "cities": { 8 | "name": "cities", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | } 23 | }, 24 | "indexes": {}, 25 | "foreignKeys": {}, 26 | "compositePrimaryKeys": {} 27 | }, 28 | "users": { 29 | "name": "users", 30 | "schema": "", 31 | "columns": { 32 | "id": { 33 | "name": "id", 34 | "type": "serial", 35 | "primaryKey": true, 36 | "notNull": true 37 | }, 38 | "full_name": { 39 | "name": "full_name", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": true 43 | }, 44 | "phone": { 45 | "name": "phone", 46 | "type": "varchar(20)", 47 | "primaryKey": false, 48 | "notNull": true 49 | }, 50 | "role": { 51 | "name": "role", 52 | "type": "text", 53 | "primaryKey": false, 54 | "notNull": true, 55 | "default": "'user'" 56 | }, 57 | "city_id": { 58 | "name": "city_id", 59 | "type": "integer", 60 | "primaryKey": false, 61 | "notNull": false 62 | }, 63 | "created_at": { 64 | "name": "created_at", 65 | "type": "timestamp", 66 | "primaryKey": false, 67 | "notNull": true, 68 | "default": "now()" 69 | }, 70 | "updated_at": { 71 | "name": "updated_at", 72 | "type": "timestamp", 73 | "primaryKey": false, 74 | "notNull": true, 75 | "default": "now()" 76 | } 77 | }, 78 | "indexes": {}, 79 | "foreignKeys": { 80 | "users_city_id_cities_id_fk": { 81 | "name": "users_city_id_cities_id_fk", 82 | "tableFrom": "users", 83 | "tableTo": "cities", 84 | "columnsFrom": [ 85 | "city_id" 86 | ], 87 | "columnsTo": [ 88 | "id" 89 | ], 90 | "onDelete": "no action", 91 | "onUpdate": "no action" 92 | } 93 | }, 94 | "compositePrimaryKeys": {} 95 | } 96 | }, 97 | "enums": {}, 98 | "schemas": {}, 99 | "_meta": { 100 | "schemas": {}, 101 | "tables": {}, 102 | "columns": {} 103 | } 104 | } -------------------------------------------------------------------------------- /test/migrations/pg/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "9f884ab5-e263-4ba4-978c-3f8a8f94a066", 5 | "prevId": "8953f9ad-673e-45a2-b02d-9fab8beeb159", 6 | "tables": { 7 | "cities": { 8 | "name": "cities", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | } 23 | }, 24 | "indexes": {}, 25 | "foreignKeys": {}, 26 | "compositePrimaryKeys": {} 27 | }, 28 | "users": { 29 | "name": "users", 30 | "schema": "", 31 | "columns": { 32 | "id": { 33 | "name": "id", 34 | "type": "serial", 35 | "primaryKey": true, 36 | "notNull": true 37 | }, 38 | "full_name": { 39 | "name": "full_name", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": true 43 | }, 44 | "phone": { 45 | "name": "phone", 46 | "type": "varchar(20)", 47 | "primaryKey": false, 48 | "notNull": true 49 | }, 50 | "role": { 51 | "name": "role", 52 | "type": "text", 53 | "primaryKey": false, 54 | "notNull": true, 55 | "default": "'user'" 56 | }, 57 | "city_id": { 58 | "name": "city_id", 59 | "type": "integer", 60 | "primaryKey": false, 61 | "notNull": false 62 | }, 63 | "created_at": { 64 | "name": "created_at", 65 | "type": "timestamp", 66 | "primaryKey": false, 67 | "notNull": true, 68 | "default": "now()" 69 | }, 70 | "updated_at": { 71 | "name": "updated_at", 72 | "type": "timestamp", 73 | "primaryKey": false, 74 | "notNull": true, 75 | "default": "now()" 76 | } 77 | }, 78 | "indexes": {}, 79 | "foreignKeys": { 80 | "users_city_id_cities_id_fk": { 81 | "name": "users_city_id_cities_id_fk", 82 | "tableFrom": "users", 83 | "tableTo": "cities", 84 | "columnsFrom": [ 85 | "city_id" 86 | ], 87 | "columnsTo": [ 88 | "id" 89 | ], 90 | "onDelete": "no action", 91 | "onUpdate": "no action" 92 | } 93 | }, 94 | "compositePrimaryKeys": {} 95 | }, 96 | "visits": { 97 | "name": "visits", 98 | "schema": "", 99 | "columns": { 100 | "id": { 101 | "name": "id", 102 | "type": "serial", 103 | "primaryKey": true, 104 | "notNull": true 105 | }, 106 | "value": { 107 | "name": "value", 108 | "type": "integer", 109 | "primaryKey": false, 110 | "notNull": true 111 | }, 112 | "city_id": { 113 | "name": "city_id", 114 | "type": "integer", 115 | "primaryKey": false, 116 | "notNull": true 117 | }, 118 | "user_id": { 119 | "name": "user_id", 120 | "type": "integer", 121 | "primaryKey": false, 122 | "notNull": true 123 | } 124 | }, 125 | "indexes": {}, 126 | "foreignKeys": { 127 | "visits_city_id_cities_id_fk": { 128 | "name": "visits_city_id_cities_id_fk", 129 | "tableFrom": "visits", 130 | "tableTo": "cities", 131 | "columnsFrom": [ 132 | "city_id" 133 | ], 134 | "columnsTo": [ 135 | "id" 136 | ], 137 | "onDelete": "no action", 138 | "onUpdate": "no action" 139 | }, 140 | "visits_user_id_users_id_fk": { 141 | "name": "visits_user_id_users_id_fk", 142 | "tableFrom": "visits", 143 | "tableTo": "users", 144 | "columnsFrom": [ 145 | "user_id" 146 | ], 147 | "columnsTo": [ 148 | "id" 149 | ], 150 | "onDelete": "no action", 151 | "onUpdate": "no action" 152 | } 153 | }, 154 | "compositePrimaryKeys": {} 155 | } 156 | }, 157 | "enums": {}, 158 | "schemas": {}, 159 | "_meta": { 160 | "schemas": {}, 161 | "tables": {}, 162 | "columns": {} 163 | } 164 | } -------------------------------------------------------------------------------- /test/migrations/pg/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "8c4029e6-1473-4299-a207-e20fec8581f3", 5 | "prevId": "9f884ab5-e263-4ba4-978c-3f8a8f94a066", 6 | "tables": { 7 | "cities": { 8 | "name": "cities", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | } 23 | }, 24 | "indexes": {}, 25 | "foreignKeys": {}, 26 | "compositePrimaryKeys": {} 27 | }, 28 | "users": { 29 | "name": "users", 30 | "schema": "", 31 | "columns": { 32 | "id": { 33 | "name": "id", 34 | "type": "serial", 35 | "primaryKey": true, 36 | "notNull": true 37 | }, 38 | "full_name": { 39 | "name": "full_name", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": true 43 | }, 44 | "phone": { 45 | "name": "phone", 46 | "type": "varchar(20)", 47 | "primaryKey": false, 48 | "notNull": true 49 | }, 50 | "role": { 51 | "name": "role", 52 | "type": "text", 53 | "primaryKey": false, 54 | "notNull": true, 55 | "default": "'user'" 56 | }, 57 | "city_id": { 58 | "name": "city_id", 59 | "type": "integer", 60 | "primaryKey": false, 61 | "notNull": false 62 | }, 63 | "created_at": { 64 | "name": "created_at", 65 | "type": "timestamp", 66 | "primaryKey": false, 67 | "notNull": true, 68 | "default": "now()" 69 | }, 70 | "updated_at": { 71 | "name": "updated_at", 72 | "type": "timestamp", 73 | "primaryKey": false, 74 | "notNull": true, 75 | "default": "now()" 76 | } 77 | }, 78 | "indexes": {}, 79 | "foreignKeys": { 80 | "users_city_id_cities_id_fk": { 81 | "name": "users_city_id_cities_id_fk", 82 | "tableFrom": "users", 83 | "tableTo": "cities", 84 | "columnsFrom": [ 85 | "city_id" 86 | ], 87 | "columnsTo": [ 88 | "id" 89 | ], 90 | "onDelete": "no action", 91 | "onUpdate": "no action" 92 | } 93 | }, 94 | "compositePrimaryKeys": {} 95 | }, 96 | "visits": { 97 | "name": "visits", 98 | "schema": "", 99 | "columns": { 100 | "id": { 101 | "name": "id", 102 | "type": "serial", 103 | "primaryKey": true, 104 | "notNull": true 105 | }, 106 | "value": { 107 | "name": "value", 108 | "type": "integer", 109 | "primaryKey": false, 110 | "notNull": true 111 | }, 112 | "city_id": { 113 | "name": "city_id", 114 | "type": "integer", 115 | "primaryKey": false, 116 | "notNull": true 117 | }, 118 | "user_id": { 119 | "name": "user_id", 120 | "type": "integer", 121 | "primaryKey": false, 122 | "notNull": true 123 | } 124 | }, 125 | "indexes": {}, 126 | "foreignKeys": { 127 | "visits_city_id_cities_id_fk": { 128 | "name": "visits_city_id_cities_id_fk", 129 | "tableFrom": "visits", 130 | "tableTo": "cities", 131 | "columnsFrom": [ 132 | "city_id" 133 | ], 134 | "columnsTo": [ 135 | "id" 136 | ], 137 | "onDelete": "no action", 138 | "onUpdate": "no action" 139 | }, 140 | "visits_user_id_users_id_fk": { 141 | "name": "visits_user_id_users_id_fk", 142 | "tableFrom": "visits", 143 | "tableTo": "users", 144 | "columnsFrom": [ 145 | "user_id" 146 | ], 147 | "columnsTo": [ 148 | "id" 149 | ], 150 | "onDelete": "no action", 151 | "onUpdate": "no action" 152 | } 153 | }, 154 | "compositePrimaryKeys": {} 155 | } 156 | }, 157 | "enums": { 158 | "role": { 159 | "name": "role", 160 | "values": { 161 | "admin": "admin", 162 | "user": "user" 163 | } 164 | } 165 | }, 166 | "schemas": {}, 167 | "_meta": { 168 | "schemas": {}, 169 | "tables": {}, 170 | "columns": {} 171 | } 172 | } -------------------------------------------------------------------------------- /test/migrations/pg/meta/0003_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "id": "4304a8ae-3889-4c49-b985-d5a602b981fd", 5 | "prevId": "8c4029e6-1473-4299-a207-e20fec8581f3", 6 | "tables": { 7 | "cities": { 8 | "name": "cities", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "serial", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "created_at": { 24 | "name": "created_at", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": true, 28 | "default": "now()" 29 | }, 30 | "updated_at": { 31 | "name": "updated_at", 32 | "type": "timestamp", 33 | "primaryKey": false, 34 | "notNull": true, 35 | "default": "now()" 36 | } 37 | }, 38 | "indexes": {}, 39 | "foreignKeys": {}, 40 | "compositePrimaryKeys": {} 41 | }, 42 | "users": { 43 | "name": "users", 44 | "schema": "", 45 | "columns": { 46 | "id": { 47 | "name": "id", 48 | "type": "serial", 49 | "primaryKey": true, 50 | "notNull": true 51 | }, 52 | "full_name": { 53 | "name": "full_name", 54 | "type": "text", 55 | "primaryKey": false, 56 | "notNull": true 57 | }, 58 | "phone": { 59 | "name": "phone", 60 | "type": "varchar(20)", 61 | "primaryKey": false, 62 | "notNull": true 63 | }, 64 | "role": { 65 | "name": "role", 66 | "type": "text", 67 | "primaryKey": false, 68 | "notNull": true, 69 | "default": "'user'" 70 | }, 71 | "city_id": { 72 | "name": "city_id", 73 | "type": "integer", 74 | "primaryKey": false, 75 | "notNull": false 76 | }, 77 | "created_at": { 78 | "name": "created_at", 79 | "type": "timestamp", 80 | "primaryKey": false, 81 | "notNull": true, 82 | "default": "now()" 83 | }, 84 | "updated_at": { 85 | "name": "updated_at", 86 | "type": "timestamp", 87 | "primaryKey": false, 88 | "notNull": true, 89 | "default": "now()" 90 | } 91 | }, 92 | "indexes": {}, 93 | "foreignKeys": { 94 | "users_city_id_cities_id_fk": { 95 | "name": "users_city_id_cities_id_fk", 96 | "tableFrom": "users", 97 | "tableTo": "cities", 98 | "columnsFrom": [ 99 | "city_id" 100 | ], 101 | "columnsTo": [ 102 | "id" 103 | ], 104 | "onDelete": "no action", 105 | "onUpdate": "no action" 106 | } 107 | }, 108 | "compositePrimaryKeys": {} 109 | }, 110 | "visits": { 111 | "name": "visits", 112 | "schema": "", 113 | "columns": { 114 | "id": { 115 | "name": "id", 116 | "type": "serial", 117 | "primaryKey": true, 118 | "notNull": true 119 | }, 120 | "value": { 121 | "name": "value", 122 | "type": "integer", 123 | "primaryKey": false, 124 | "notNull": true 125 | }, 126 | "city_id": { 127 | "name": "city_id", 128 | "type": "integer", 129 | "primaryKey": false, 130 | "notNull": true 131 | }, 132 | "user_id": { 133 | "name": "user_id", 134 | "type": "integer", 135 | "primaryKey": false, 136 | "notNull": true 137 | } 138 | }, 139 | "indexes": {}, 140 | "foreignKeys": { 141 | "visits_city_id_cities_id_fk": { 142 | "name": "visits_city_id_cities_id_fk", 143 | "tableFrom": "visits", 144 | "tableTo": "cities", 145 | "columnsFrom": [ 146 | "city_id" 147 | ], 148 | "columnsTo": [ 149 | "id" 150 | ], 151 | "onDelete": "no action", 152 | "onUpdate": "no action" 153 | }, 154 | "visits_user_id_users_id_fk": { 155 | "name": "visits_user_id_users_id_fk", 156 | "tableFrom": "visits", 157 | "tableTo": "users", 158 | "columnsFrom": [ 159 | "user_id" 160 | ], 161 | "columnsTo": [ 162 | "id" 163 | ], 164 | "onDelete": "no action", 165 | "onUpdate": "no action" 166 | } 167 | }, 168 | "compositePrimaryKeys": {} 169 | } 170 | }, 171 | "enums": { 172 | "role": { 173 | "name": "role", 174 | "values": { 175 | "admin": "admin", 176 | "user": "user" 177 | } 178 | } 179 | }, 180 | "schemas": {}, 181 | "_meta": { 182 | "schemas": {}, 183 | "tables": {}, 184 | "columns": {} 185 | } 186 | } -------------------------------------------------------------------------------- /test/migrations/pg/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1679836742662, 9 | "tag": "0000_overjoyed_sandman", 10 | "breakpoints": false 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1680770132388, 16 | "tag": "0001_brainy_dexter_bennett", 17 | "breakpoints": false 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "5", 22 | "when": 1682230897159, 23 | "tag": "0002_cultured_red_skull", 24 | "breakpoints": false 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "5", 29 | "when": 1682775695270, 30 | "tag": "0003_medical_shooting_star", 31 | "breakpoints": false 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /test/pg.drizzle.test.ts: -------------------------------------------------------------------------------- 1 | import * as E from "@effect/data/Either"; 2 | import * as Effect from "@effect/io/Effect"; 3 | import { it, describe, expect } from "./helpers"; 4 | import { db } from "./helpers/pg.drizzle.dsl"; 5 | import { cities } from "./helpers/pg.schema"; 6 | import { usingLayer, testLayer } from "./helpers/layer"; 7 | 8 | import { 9 | runQueryRows, 10 | runQueryOne, 11 | runQueryExactlyOne, 12 | } from "effect-sql/builders/drizzle"; 13 | import { NotFound, TooMany } from "effect-sql/errors"; 14 | 15 | usingLayer(testLayer); 16 | 17 | const selectFromCities = db.select().from(cities); 18 | const selectNameFromCities = db.select({ name: cities.name }).from(cities); 19 | const insertCity = (name: string) => db.insert(cities).values({ name }); 20 | 21 | describe("pg – drizzle", () => { 22 | it.sandbox("runQuery ==0", () => 23 | Effect.gen(function* ($) { 24 | expect((yield* $(selectFromCities, runQueryRows)).length).toEqual(0); 25 | }) 26 | ); 27 | 28 | it.sandbox("runQuery ==2", () => 29 | Effect.gen(function* ($) { 30 | yield* $(insertCity("Foo"), runQueryRows); 31 | yield* $(insertCity("Bar"), runQueryRows); 32 | expect((yield* $(selectFromCities, runQueryRows)).length).toEqual(2); 33 | }) 34 | ); 35 | 36 | it.sandbox("runQueryOne ==0: NotFound", () => 37 | Effect.gen(function* ($) { 38 | const res1 = yield* $(selectFromCities, runQueryOne, Effect.either); 39 | 40 | expect(res1).toEqual( 41 | E.left( 42 | new NotFound({ 43 | sql: 'select "id", "name", "created_at", "updated_at" from "cities"', 44 | parameters: [], 45 | }) 46 | ) 47 | ); 48 | }) 49 | ); 50 | 51 | it.sandbox("runQueryOne ==1: finds record", () => 52 | Effect.gen(function* ($) { 53 | yield* $(insertCity("Foo"), runQueryRows); 54 | 55 | const res2 = yield* $(selectNameFromCities, runQueryOne, Effect.either); 56 | 57 | expect(res2).toEqual(E.right({ name: "Foo" })); 58 | }) 59 | ); 60 | 61 | it.sandbox("runQueryOne ==2: finds record", () => 62 | Effect.gen(function* ($) { 63 | yield* $(insertCity("Foo"), runQueryRows); 64 | yield* $(insertCity("Bar"), runQueryRows); 65 | 66 | const res2 = yield* $(selectNameFromCities, runQueryOne, Effect.either); 67 | 68 | expect(res2).toEqual(E.right({ name: "Foo" })); 69 | }) 70 | ); 71 | 72 | it.sandbox("runQueryExactlyOne ==0: NotFound", () => 73 | Effect.gen(function* ($) { 74 | const res1 = yield* $( 75 | selectFromCities, 76 | runQueryExactlyOne, 77 | Effect.either 78 | ); 79 | 80 | expect(res1).toEqual( 81 | E.left( 82 | new NotFound({ 83 | sql: 'select "id", "name", "created_at", "updated_at" from "cities"', 84 | parameters: [], 85 | }) 86 | ) 87 | ); 88 | }) 89 | ); 90 | 91 | it.sandbox("runQueryExactlyOne ==1: finds record", () => 92 | Effect.gen(function* ($) { 93 | yield* $(insertCity("Foo"), runQueryRows); 94 | 95 | const res2 = yield* $( 96 | selectNameFromCities, 97 | runQueryExactlyOne, 98 | Effect.either 99 | ); 100 | 101 | expect(res2).toEqual(E.right({ name: "Foo" })); 102 | }) 103 | ); 104 | 105 | it.sandbox("runQueryExactlyOne ==2: finds record", () => 106 | Effect.gen(function* ($) { 107 | yield* $(insertCity("Foo"), runQueryRows); 108 | yield* $(insertCity("Bar"), runQueryRows); 109 | 110 | const res2 = yield* $( 111 | selectNameFromCities, 112 | runQueryExactlyOne, 113 | Effect.either 114 | ); 115 | 116 | expect(res2).toEqual( 117 | E.left( 118 | new TooMany({ 119 | sql: 'select "name" from "cities"', 120 | parameters: [], 121 | }) 122 | ) 123 | ); 124 | }) 125 | ); 126 | 127 | it.sandbox.fails("respects case", () => 128 | Effect.gen(function* ($) { 129 | yield* $(insertCity("Foo"), runQueryRows); 130 | 131 | const res2 = yield* $( 132 | db.select({ cityName: cities.name }).from(cities), 133 | runQueryOne, 134 | Effect.either 135 | ); 136 | 137 | expect(res2).toEqual(E.right({ cityName: "Foo" })); 138 | }) 139 | ); 140 | }); 141 | -------------------------------------------------------------------------------- /test/pg.kysely.test.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "@effect/data/Function"; 2 | import * as E from "@effect/data/Either"; 3 | import * as Effect from "@effect/io/Effect"; 4 | import * as Layer from "@effect/io/Layer"; 5 | import { it, describe, expect } from "./helpers"; 6 | import { 7 | runQuery, 8 | runQueryRows, 9 | runQueryExactlyOne, 10 | runQueryOne, 11 | KyselyQueryBuilder, 12 | } from "effect-sql/builders/kysely"; 13 | import { NotFound, TooMany } from "effect-sql/errors"; 14 | import { City, User, db } from "./helpers/pg.kysely.dsl"; 15 | import { jsonAgg } from "./helpers/json"; 16 | import { usingLayer, testLayer } from "./helpers/layer"; 17 | 18 | const select = db.selectFrom("cities").selectAll(); 19 | const selectName = db.selectFrom("cities").select("name"); 20 | const insert = (name: string) => db.insertInto("cities").values({ name }); 21 | 22 | usingLayer( 23 | Layer.provideMerge(testLayer, Layer.succeed(KyselyQueryBuilder, db)) 24 | ); 25 | 26 | describe("pg – kysely", () => { 27 | it.sandbox("runQuery ==0", () => 28 | Effect.gen(function* ($) { 29 | expect((yield* $(select, runQueryRows)).length).toEqual(0); 30 | }) 31 | ); 32 | 33 | it.sandbox("runQuery ==2", () => 34 | Effect.gen(function* ($) { 35 | yield* $(insert("foo"), runQueryRows); 36 | yield* $(insert("bar"), runQueryRows); 37 | 38 | expect((yield* $(select, runQueryRows)).length).toEqual(2); 39 | }) 40 | ); 41 | 42 | it.sandbox("runQueryOne ==0: NotFound", () => 43 | Effect.gen(function* ($) { 44 | const res1 = yield* $(select, runQueryOne, Effect.either); 45 | 46 | expect(res1).toEqual( 47 | E.left( 48 | new NotFound({ 49 | sql: 'select * from "cities"', 50 | parameters: [], 51 | }) 52 | ) 53 | ); 54 | }) 55 | ); 56 | 57 | it.sandbox("runQueryOne ==1: finds record", () => 58 | Effect.gen(function* ($) { 59 | yield* $(insert("foo"), runQueryRows); 60 | 61 | const res2 = yield* $(selectName, runQueryOne, Effect.either); 62 | expect(res2).toEqual(E.right({ name: "foo" })); 63 | }) 64 | ); 65 | 66 | it.sandbox("runQueryOne ==2: finds record", () => 67 | Effect.gen(function* ($) { 68 | yield* $(insert("foo"), runQueryRows); 69 | yield* $(insert("bar"), runQueryRows); 70 | 71 | const res2 = yield* $(selectName, runQueryOne, Effect.either); 72 | 73 | expect(res2).toEqual(E.right({ name: "foo" })); 74 | }) 75 | ); 76 | 77 | it.sandbox("runQueryExactlyOne ==0: NotFound", () => 78 | Effect.gen(function* ($) { 79 | const res1 = yield* $(select, runQueryExactlyOne, Effect.either); 80 | 81 | expect(res1).toEqual( 82 | E.left( 83 | new NotFound({ 84 | sql: 'select * from "cities"', 85 | parameters: [], 86 | }) 87 | ) 88 | ); 89 | }) 90 | ); 91 | 92 | it.sandbox("runQueryExactlyOne ==1: finds record", () => 93 | Effect.gen(function* ($) { 94 | yield* $(insert("foo"), runQueryRows); 95 | 96 | const res2 = yield* $(selectName, runQueryExactlyOne, Effect.either); 97 | expect(res2).toEqual(E.right({ name: "foo" })); 98 | }) 99 | ); 100 | 101 | it.sandbox("runQueryExactlyOne ==2: finds record", () => 102 | Effect.gen(function* ($) { 103 | yield* $(insert("foo"), runQueryRows); 104 | yield* $(insert("bar"), runQueryRows); 105 | 106 | const res2 = yield* $(selectName, runQueryExactlyOne, Effect.either); 107 | 108 | expect(res2).toEqual( 109 | E.left( 110 | new TooMany({ 111 | sql: 'select "name" from "cities"', 112 | parameters: [], 113 | }) 114 | ) 115 | ); 116 | }) 117 | ); 118 | 119 | it.sandbox("respects case (as)", () => 120 | Effect.gen(function* ($) { 121 | yield* $(insert("Foo"), runQueryRows); 122 | 123 | const res2 = yield* $( 124 | db.selectFrom("cities").select("name as cityName"), 125 | runQueryOne, 126 | Effect.either 127 | ); 128 | 129 | expect(res2).toEqual(E.right({ cityName: "Foo" })); 130 | }) 131 | ); 132 | 133 | it.sandbox("respects case (runQuery*)", () => 134 | Effect.gen(function* ($) { 135 | yield* $(insert("Foo"), runQueryRows); 136 | const query = db.selectFrom("cities").select("createdAt"); 137 | 138 | const res2 = yield* $( 139 | Effect.all([ 140 | Effect.map(runQuery(query), (_) => _.rows[0]!), 141 | Effect.map(runQueryRows(query), (_) => _[0]!), 142 | runQueryOne(query), 143 | runQueryExactlyOne(query), 144 | ]), 145 | Effect.either 146 | ); 147 | 148 | expect(res2).toHaveProperty("right[0].createdAt"); 149 | expect(res2).toHaveProperty("right[1].createdAt"); 150 | expect(res2).toHaveProperty("right[2].createdAt"); 151 | expect(res2).toHaveProperty("right[3].createdAt"); 152 | }) 153 | ); 154 | 155 | it.sandbox("json_agg", () => 156 | Effect.gen(function* ($) { 157 | const insertCity = (name: string) => 158 | pipe( 159 | db.insertInto("cities").values({ name }).returningAll(), 160 | runQueryExactlyOne 161 | ); 162 | 163 | const insertUser = (fullName: string) => 164 | pipe( 165 | db 166 | .insertInto("users") 167 | .values({ fullName: fullName, phone: "+39012321" }) 168 | .returningAll(), 169 | runQueryExactlyOne 170 | ); 171 | 172 | const insertVisits = (city: City, user: User, value: number) => 173 | pipe( 174 | db 175 | .insertInto("visits") 176 | .values({ cityId: city.id, userId: user.id, value }) 177 | .returningAll(), 178 | runQueryExactlyOne 179 | ); 180 | 181 | const factory = yield* $( 182 | Effect.all({ 183 | tokyo: insertCity("Tokyo"), 184 | kyoto: insertCity("Kyoto"), 185 | osaka: insertCity("Osaka"), 186 | haruhi: insertUser("Haruhi"), 187 | nagato: insertUser("Nagato"), 188 | }), 189 | Effect.tap((_) => 190 | Effect.all([ 191 | insertVisits(_.kyoto, _.haruhi, 12), 192 | insertVisits(_.tokyo, _.haruhi, 17), 193 | insertVisits(_.osaka, _.haruhi, 10), 194 | insertVisits(_.tokyo, _.nagato, 9999), 195 | ]) 196 | ) 197 | ); 198 | 199 | const manyToManySub = yield* $( 200 | db 201 | .selectFrom("users") 202 | .selectAll() 203 | .select((eb) => 204 | jsonAgg( 205 | eb 206 | .selectFrom("visits") 207 | .leftJoin("cities", "cities.id", "visits.cityId") 208 | .select(["visits.value as count", "cities.name as cityName"]) 209 | .whereRef("visits.userId", "=", "users.id") 210 | .orderBy("visits.value", "desc") 211 | ).as("visited") 212 | ), 213 | runQueryRows 214 | ); 215 | 216 | expect(manyToManySub[0]?.id).toEqual(factory.haruhi.id); 217 | expect(manyToManySub[0]?.visited.length).toEqual(3); 218 | expect(manyToManySub[0]?.visited[0]?.cityName).toEqual( 219 | factory.tokyo.name 220 | ); 221 | }) 222 | ); 223 | }); 224 | -------------------------------------------------------------------------------- /test/pg.test.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "@effect/data/Function"; 2 | import * as E from "@effect/data/Either"; 3 | import * as Effect from "@effect/io/Effect"; 4 | import * as Layer from "@effect/io/Layer"; 5 | import * as Config from "@effect/io/Config"; 6 | import * as ConfigSecret from "@effect/io/Config/Secret"; 7 | import { it, describe, expect } from "./helpers"; 8 | import { 9 | ConnectionPool, 10 | ConnectionPoolScopedService, 11 | runQuery, 12 | runQueryOne, 13 | runQueryExactlyOne, 14 | transaction, 15 | IsolationLevel, 16 | Serializable, 17 | } from "effect-sql/query"; 18 | import { DatabaseError, NotFound, TooMany } from "effect-sql/errors"; 19 | import { usingLayer, testLayer } from "./helpers/layer"; 20 | 21 | usingLayer(testLayer); 22 | 23 | const select = `select * from "cities"`; 24 | const selectName = `select "name" from "cities"`; 25 | const insert = (name: string) => `insert into cities(name) values('${name}')`; 26 | 27 | describe("pg", () => { 28 | it.sandbox("runQuery ==0", () => 29 | Effect.gen(function* ($) { 30 | expect((yield* $(select, runQuery)).rows.length).toEqual(0); 31 | }) 32 | ); 33 | 34 | it.sandbox("runQuery ==2", () => 35 | Effect.gen(function* ($) { 36 | yield* $(insert("foo"), runQuery); 37 | yield* $(insert("bar"), runQuery); 38 | 39 | expect((yield* $(select, runQuery)).rows.length).toEqual(2); 40 | }) 41 | ); 42 | 43 | it.sandbox("runQueryOne ==0: NotFound", () => 44 | Effect.gen(function* ($) { 45 | const res1 = yield* $(select, runQueryOne, Effect.either); 46 | 47 | expect(res1).toEqual( 48 | E.left( 49 | new NotFound({ 50 | sql: 'select * from "cities"', 51 | parameters: [], 52 | }) 53 | ) 54 | ); 55 | }) 56 | ); 57 | 58 | it.sandbox("runQueryOne ==1: finds record", () => 59 | Effect.gen(function* ($) { 60 | yield* $(insert("foo"), runQuery); 61 | 62 | const res2 = yield* $(selectName, runQueryOne, Effect.either); 63 | expect(res2).toEqual(E.right({ name: "foo" })); 64 | }) 65 | ); 66 | 67 | it.sandbox("runQueryOne ==2: finds record", () => 68 | Effect.gen(function* ($) { 69 | yield* $(insert("foo"), runQuery); 70 | yield* $(insert("bar"), runQuery); 71 | 72 | const res2 = yield* $(selectName, runQueryOne, Effect.either); 73 | 74 | expect(res2).toEqual(E.right({ name: "foo" })); 75 | }) 76 | ); 77 | 78 | it.sandbox("runQueryExactlyOne ==0: NotFound", () => 79 | Effect.gen(function* ($) { 80 | const res1 = yield* $(select, runQueryExactlyOne, Effect.either); 81 | 82 | expect(res1).toEqual( 83 | E.left( 84 | new NotFound({ 85 | sql: 'select * from "cities"', 86 | parameters: [], 87 | }) 88 | ) 89 | ); 90 | }) 91 | ); 92 | 93 | it.sandbox("runQueryExactlyOne ==1: finds record", () => 94 | Effect.gen(function* ($) { 95 | yield* $(insert("foo"), runQuery); 96 | 97 | const res2 = yield* $(selectName, runQueryExactlyOne, Effect.either); 98 | expect(res2).toEqual(E.right({ name: "foo" })); 99 | }) 100 | ); 101 | 102 | it.sandbox("runQueryExactlyOne ==2: finds record", () => 103 | Effect.gen(function* ($) { 104 | yield* $(insert("foo"), runQuery); 105 | yield* $(insert("bar"), runQuery); 106 | 107 | const res2 = yield* $(selectName, runQueryExactlyOne, Effect.either); 108 | 109 | expect(res2).toEqual( 110 | E.left( 111 | new TooMany({ 112 | sql: 'select "name" from "cities"', 113 | parameters: [], 114 | }) 115 | ) 116 | ); 117 | }) 118 | ); 119 | 120 | it.sandbox("handle QueryError", () => 121 | Effect.gen(function* ($) { 122 | const res = yield* $("select * from dontexist;", runQuery, Effect.either); 123 | 124 | expect(res).toEqual( 125 | E.left( 126 | new DatabaseError({ 127 | code: "42P01", 128 | name: "QueryError", 129 | message: `relation "dontexist" does not exist`, 130 | }) 131 | ) 132 | ); 133 | }) 134 | ); 135 | 136 | it.effect("handle PoolError", () => 137 | Effect.gen(function* ($) { 138 | const res = yield* $( 139 | select, 140 | runQuery, 141 | Effect.provideSomeLayer( 142 | Layer.scoped( 143 | ConnectionPool, 144 | ConnectionPoolScopedService({ 145 | databaseUrl: Config.succeed( 146 | ConfigSecret.fromString("postgres://127.0.0.1:80") 147 | ), 148 | }) 149 | ) 150 | ), 151 | Effect.either 152 | ); 153 | 154 | expect(res).toEqual( 155 | E.left( 156 | new DatabaseError({ 157 | name: "ConnectionPoolError", 158 | message: `connect ECONNREFUSED 127.0.0.1:80`, 159 | }) 160 | ) 161 | ); 162 | }) 163 | ); 164 | 165 | it.sandbox("transactions", () => 166 | Effect.gen(function* ($) { 167 | const count = pipe( 168 | select, 169 | runQuery, 170 | Effect.map((_) => _.rows.length) 171 | ); 172 | 173 | yield* $(insert("foo"), runQuery, transaction); 174 | expect(yield* $(count)).toEqual(1); 175 | 176 | yield* $(insert("foo"), runQuery, transaction); 177 | expect(yield* $(count)).toEqual(2); 178 | 179 | yield* $( 180 | Effect.all([runQuery(insert("foo")), Effect.fail("fail")]), 181 | transaction, 182 | Effect.either 183 | ); 184 | 185 | expect(yield* $(count)).toEqual(2); 186 | }) 187 | ); 188 | 189 | it.effect("create database", () => 190 | Effect.gen(function* ($) { 191 | const res = yield* $( 192 | Effect.all([ 193 | runQuery(`drop database if exists "foo"`), 194 | runQuery(`create database "foo"`), 195 | runQuery(`drop database "foo"`), 196 | ]), 197 | Effect.zipRight(Effect.succeed("ok")), 198 | Effect.either 199 | ); 200 | 201 | expect(res).toEqual(E.right("ok")); 202 | }) 203 | ); 204 | 205 | it.effect("isolation level service", () => 206 | Effect.gen(function* ($) { 207 | const res = yield* $( 208 | `show transaction isolation level`, 209 | runQueryExactlyOne, 210 | transaction, 211 | Effect.provideService(IsolationLevel, Serializable), 212 | Effect.either 213 | ); 214 | 215 | expect(res).toEqual(E.right({ transaction_isolation: "serializable" })); 216 | }) 217 | ); 218 | }); 219 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleDetection": "force", 4 | "composite": true, 5 | "downlevelIteration": true, 6 | "resolveJsonModule": true, 7 | "esModuleInterop": true, 8 | "declaration": true, 9 | "skipLibCheck": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "preserveSymlinks": true, 13 | "moduleResolution": "node", 14 | "noEmit": false, 15 | "lib": ["ES2021"], 16 | "sourceMap": true, 17 | "declarationMap": true, 18 | "strict": true, 19 | "noImplicitReturns": false, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": false, 22 | "noFallthroughCasesInSwitch": true, 23 | "noEmitOnError": false, 24 | "noErrorTruncation": false, 25 | "allowJs": false, 26 | "checkJs": false, 27 | "forceConsistentCasingInFileNames": true, 28 | "stripInternal": true, 29 | "noImplicitAny": true, 30 | "noImplicitThis": true, 31 | "noUncheckedIndexedAccess": true, 32 | "strictNullChecks": true, 33 | "baseUrl": ".", 34 | "target": "ES2021", 35 | "module": "ES6", 36 | "incremental": true, 37 | "removeComments": false, 38 | "paths": { 39 | "effect-sql": ["./src/index.ts"], 40 | "effect-sql/test/*": ["./test/*"], 41 | "effect-sql/examples/*": ["./examples/*"], 42 | "effect-sql/*": ["./src/*"] 43 | }, 44 | "plugins": [ 45 | { 46 | "name": "@effect/language-service", 47 | "diagnostics": { 48 | "1002": "none" 49 | } 50 | } 51 | ] 52 | }, 53 | "include": [], 54 | "exclude": ["node_modules", "build", "lib"] 55 | } 56 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "build/esm", 5 | "declarationDir": "build/dts", 6 | "tsBuildInfoFile": "build/tsbuildinfo/esm.tsbuildinfo", 7 | "rootDir": "src" 8 | }, 9 | "include": ["src/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "build/tsbuildinfo/examples.tsbuildinfo", 5 | "rootDir": "examples", 6 | "module": "CommonJS", 7 | "outDir": "build/examples" 8 | }, 9 | "include": ["examples/**/*.ts"], 10 | "references": [{ "path": "./tsconfig.build.json" }] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "references": [ 4 | { 5 | "path": "./tsconfig.build.json" 6 | }, 7 | { 8 | "path": "./tsconfig.test.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.madge.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "allowJs": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "@effect/data": ["./build/esm/index.js"], 8 | "@effect/data/*": ["./build/esm/*"] 9 | } 10 | }, 11 | "include": ["./build/esm/**/*.js"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "build/tsbuildinfo/test.tsbuildinfo", 5 | "rootDir": "./", 6 | "noEmit": true, 7 | "types": ["vitest/globals", "node"] 8 | }, 9 | "include": ["test/**/*.ts", "src/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vitest/config"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [tsconfigPaths()], 7 | test: { 8 | include: ["./test/**/*.test.{js,mjs,cjs,ts,mts,cts}"], 9 | }, 10 | }); 11 | --------------------------------------------------------------------------------