├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RESCRIPT.md ├── demo.gif ├── docs-new ├── .gitignore ├── README.md ├── docs │ ├── cli.md │ ├── dynamic-queries.md │ ├── faq.md │ ├── features.md │ ├── getting-started.md │ ├── intro.md │ ├── sql-file-intro.md │ ├── sql-file.md │ ├── ts-file-intro.md │ ├── ts-file.md │ └── typing.md ├── docusaurus.config.js ├── package-lock.json ├── package.json ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ └── styles.module.css └── static │ └── img │ ├── favicon.ico │ ├── integrity.svg │ ├── logo.svg │ ├── multifile.svg │ └── typesafety.svg ├── header.png ├── jest.config.ts ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── cli │ ├── .gitignore │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── rescript.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── parseTypescript.test.ts.snap │ │ ├── config.ts │ │ ├── declareImport.test.ts │ │ ├── generator.test.ts │ │ ├── generator.ts │ │ ├── index.ts │ │ ├── parseRescript.ts │ │ ├── parseTypescript.test.ts │ │ ├── parseTypescript.ts │ │ ├── res │ │ │ └── PgTyped.res │ │ ├── rescript.test.ts │ │ ├── rescript.test_disabled.ts │ │ ├── stringToType.test.ts │ │ ├── types.test.ts │ │ ├── types.ts │ │ ├── util.ts │ │ └── worker.ts │ └── tsconfig.json ├── example │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── check-git-diff.sh │ ├── config.json │ ├── docker-compose.yml │ ├── jest-cjs.config.ts │ ├── jest.config.ts │ ├── package.json │ ├── rescript.json │ ├── scripts │ │ └── wait-for-postgres-then │ ├── sql │ │ └── schema.sql │ ├── src │ │ ├── __snapshots__ │ │ │ ├── index.test.ts.snap │ │ │ └── rescript.test.js.snap │ │ ├── books │ │ │ ├── BookService.res │ │ │ ├── BookService__sql.gen.tsx │ │ │ ├── BookService__sql.res │ │ │ ├── books.queries.ts │ │ │ ├── books.sql │ │ │ ├── books__sql.gen.tsx │ │ │ └── books__sql.res │ │ ├── comments │ │ │ ├── comments.queries.ts │ │ │ ├── comments.sql │ │ │ ├── comments__sql.gen.tsx │ │ │ └── comments__sql.res │ │ ├── customTypes.ts │ │ ├── index.test.ts │ │ ├── notifications │ │ │ ├── notifications.queries.ts │ │ │ ├── notifications.sql │ │ │ ├── notifications.ts │ │ │ ├── notifications.types.ts │ │ │ ├── notifications__sql.gen.tsx │ │ │ └── notifications__sql.res │ │ ├── rescript.test.res │ │ └── users │ │ │ ├── sample.ts │ │ │ └── sample.types.ts │ └── tsconfig.json ├── parser │ ├── .gitignore │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── loader │ │ │ ├── sql │ │ │ ├── __snapshots__ │ │ │ │ └── index.test.ts.snap │ │ │ ├── grammar │ │ │ │ ├── SQLLexer.g4 │ │ │ │ └── SQLParser.g4 │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── logger.ts │ │ │ └── parser │ │ │ │ ├── SQLLexer.ts │ │ │ │ ├── SQLParser.ts │ │ │ │ ├── SQLParserListener.ts │ │ │ │ └── SQLParserVisitor.ts │ │ │ └── typescript │ │ │ ├── __snapshots__ │ │ │ └── query.test.ts.snap │ │ │ ├── grammar │ │ │ ├── QueryLexer.g4 │ │ │ └── QueryParser.g4 │ │ │ ├── parser │ │ │ ├── QueryLexer.ts │ │ │ ├── QueryParser.ts │ │ │ ├── QueryParserListener.ts │ │ │ └── QueryParserVisitor.ts │ │ │ ├── query.test.ts │ │ │ └── query.ts │ └── tsconfig.json ├── query │ ├── .gitignore │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── actions.test.ts.snap │ │ ├── actions.test.ts │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── sasl-helpers.test.ts │ │ ├── sasl-helpers.ts │ │ └── type.ts │ └── tsconfig.json ├── runtime │ ├── .gitignore │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── preprocessor-sql.test.ts │ │ ├── preprocessor-sql.ts │ │ ├── preprocessor-ts.test.ts │ │ ├── preprocessor-ts.ts │ │ ├── preprocessor.ts │ │ └── tag.ts │ └── tsconfig.json └── wire │ ├── .gitignore │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── src │ ├── helpers.test.ts │ ├── helpers.ts │ ├── index.ts │ ├── messages.ts │ ├── protocol.test.ts │ ├── protocol.ts │ └── queue.ts │ └── tsconfig.json ├── renovate.json ├── tsconfig.json ├── tslint.json └── vercel.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: pgtyped 2 | github: adelsz 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and description of what the bug is. 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Test case** 17 | 18 | If applicable, it would really help if you can add an end-to-end test-case to the `packages/example` project. 19 | 20 | The package allows you to do the following: 21 | - Modify the database schema at `packages/example/sql/schema.sql`. 22 | - Define your SQL query as a `.ts` or `.sql` anywhere in the `packages/example/src` directory. 23 | - Run your SQL as a Jest test case in `packages/example/src/index.test.ts` 24 | 25 | Please refer to `contributing.md` for details on how to run the `example` package. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - rescript 7 | pull_request: 8 | branches: 9 | - rescript 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: ['20'] 17 | name: Test (node ${{ matrix.node-version }}.x) 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - run: npm ci 26 | - name: Build packages 27 | run: npm run build 28 | - name: Run tests 29 | run: npm test 30 | env: 31 | NODE_VERSION: ${{ matrix.node-version }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | .idea/ 3 | 4 | # Ignore build files 5 | packages/**/*.js 6 | packages/**/*.d.ts 7 | packages/**/*.js.map 8 | *.java 9 | *.interp 10 | *.tokens 11 | *.log 12 | 13 | .vercel 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | packages/example 2 | packages/query/src/loader/*/parser/ -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: 'all' 2 | singleQuote: true 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # main 2 | 3 | # 2.6.0 4 | 5 | - Improve `pg` bindings. 6 | - Upgrade `@rescript/core` to `1.6.0`. 7 | 8 | # 2.5.0 9 | 10 | - Autoinsert trailing commas in embedded SQL blocks. 11 | - BREAKING CHANGE: `Null.t` is no longer emitted, all `null` values are autoconverted to `option`. This gives a much more idiomatic ReScript experience. 12 | - Emit actually runnable query in module comment for each query, instead of the original non-valid SQL query. 13 | - Relax requirement on providing query via `@name` comment. 14 | - Change `expectOne` to panic if not finding a single item. 15 | 16 | # 2.4.0 17 | 18 | - Add mode for embedding `%sql` (with `one`, `expectOne`, `many`, and `execute` flavors) in ReScript directly. 19 | 20 | # 2.3.1 21 | 22 | - Fix missing `rescript.json` in published package. 23 | 24 | # 2.3.0 25 | 26 | - Fix type generation for arrays of types like `JSON.t`. 27 | - BREAKING: Up required ReScript version to `>=11.1.0` and `@rescript/core` to `>=1.3.0`. 28 | - Proper `bigint` support. 29 | - Emit `@gentype` annotations for everything. 30 | - Add each query to its own ReScript module, and emit helpers `many`, `one`, `expectOne` and `execute`. 31 | 32 | # 2.2.2 33 | 34 | - [CLI] bin is now named `pgtyped-rescript` so it won't clash with stock `pgtyped`. 35 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pgTyped 2 | 3 | pgTyped is an open source project, and we welcome contributions of all kinds, including bug reports, feature requests, and pull requests. 4 | 5 | # How to contribute? 6 | 7 | Our rules for pull requests and issues are fairly standard and flexible. When submitting a change, please provide a brief and descriptive title, if possible written in the imperative mood. 8 | 9 | If you have an idea for a new feature or want to address a bug, it's recommended that you first open an issue. We're available to assist and discuss the process of opening a pull request to ensure your changes are incorporated. 10 | 11 | We highly recommend you include a test-case added to the `packages/example` project when you submit a pull request or an issue. 12 | 13 | This will help us verify your issue or pull request and prevent regressions in the future. 14 | 15 | # Development Setup 16 | 17 | To get started, clone the repository and install the dependencies: 18 | 19 | ```bash 20 | git clone git@github.com:adelsz/pgtyped.git 21 | cd pgtyped 22 | npm install 23 | ``` 24 | 25 | We use a mono-repo setup with [Lerna](https://lernajs.io/) and NPM workspaces. 26 | This means that running `npm install` will install all the dependencies for all the packages in the project. 27 | It will also link the packages together, so that you can make changes to one package and immediately see the effects in another package. 28 | 29 | The `packages` directory contains the source code for the various components of pgTyped: 30 | 31 | - `packages/cli` - The CLI tool for generating TypeScript types from SQL files 32 | - `packages/wire` - The pgTyped PostgreSQL wire protocol implementation 33 | - `packages/parser` - The pgTyped SQL and TS language parser 34 | - `packages/runtime` - The pgTyped runtime library that provides the `sql` template tag and the `sql` function for executing queries. 35 | - `packages/query` - This package contains higher level PostgreSQL protocol utilities for describing query types, SSL support, and more. 36 | - `packages/example` - This repository contains a simple example of a pgTyped project written as a Jest test suite. We use this project both as a demonstration of pgTyped and as an end-to-end test suite for the project. 37 | 38 | To build the project, run: 39 | 40 | ```bash 41 | npm run build 42 | ``` 43 | 44 | This will build all the packages in the project. To run build in watch mode, run: 45 | 46 | ```bash 47 | npm run watch 48 | ``` 49 | 50 | To run the tests, run: 51 | 52 | ```bash 53 | npm test 54 | ``` 55 | 56 | It will run the tests for all the packages in the project, including end-to-end tests for the example project. 57 | 58 | # The `packages/example` project 59 | 60 | The `packages/example` project is an end-to-end test suite for pgTyped. It contains a simple example of a pgTyped project written as a Jest test suite. 61 | 62 | The packages `npm test` runs the following command: 63 | 64 | ```bash 65 | docker-compose run build && docker-compose run test && docker-compose run test-cjs 66 | ``` 67 | 68 | As you can see it runs the `build` target, then runs the `test` target twice, once with the `esm` module format and once with the `cjs` module format: 69 | - The `build` target runs pgTyped on the `sql` files in the `packages/example/src` directory generating the query code and type definitions. It also runs `git diff` to verify that the generated code matches the code in the repository. 70 | - The `test` target runs the queries in `packages/example/src/index.ts` and verifies that the results match the expected results. 71 | - The `test-cjs` target runs the same tests as the `test` target, but using the `cjs` module format to verify that the generated code works with both module formats. 72 | 73 | All the targets are run in a Docker container, with a Postgres database running in a separate container spun up by Docker Compose. 74 | 75 | The definitions of each of these targets and the DB service can be found in the `packages/example/docker-compose.yml` file. 76 | 77 | The database is initialized with the `sql/schema.sql` file. 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Adel Salakh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # [PgTyped](https://pgtyped.dev/) 4 | 5 | ![Version](https://img.shields.io/github/v/release/adelsz/pgtyped) 6 | [![Actions Status](https://github.com/adelsz/pgtyped/workflows/CI/badge.svg)](https://github.com/adelsz/pgtyped/actions) [![Join the chat at https://gitter.im/pgtyped/community](https://badges.gitter.im/pgtyped/community.svg)](https://gitter.im/pgtyped/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 7 | 8 | ## ReScript fork of PgTyped 9 | 10 | > This is a fork PgTyped that outputs ReScript instead of TS. Most things work the same as the TS version. [Here's a dedicated ReScript readme](./RESCRIPT.md) detailing the differences, and how to get started in ReScript. 11 | 12 | PgTyped makes it possible to use raw SQL in TypeScript with guaranteed type-safety. 13 | No need to map or translate your DB schema to TypeScript, PgTyped automatically generates types and interfaces for your SQL queries by using your running Postgres database as the source of type information. 14 | 15 | --- 16 | 17 | ## Features: 18 | 19 | 1. Automatically generates TS types for parameters/results of SQL queries of any complexity. 20 | 2. Supports extracting and typing queries from both SQL and TS files. 21 | 3. Generate query types as you write them, using watch mode. 22 | 4. Useful parameter interpolation helpers for arrays and objects. 23 | 5. No need to define your DB schema in TypeScript, your running DB is the live source of type data. 24 | 6. Prevents SQL injections by not doing explicit parameter substitution. Instead, queries and parameters are sent separately to the DB driver, allowing parameter substitution to be safely done by the PostgreSQL server. 25 | 7. Native ESM support. Runtime dependencies are also provided as CommonJS. 26 | 27 | ### Documentation 28 | 29 | Visit our documentation page at [https://pgtyped.dev/](https://pgtyped.dev/) 30 | 31 | ### Getting started 32 | 33 | 1. `npm install -D @pgtyped/cli typescript` (typescript is a required peer dependency for pgtyped) 34 | 2. `npm install @pgtyped/runtime` (`@pgtyped/runtime` is the only required runtime dependency of pgtyped) 35 | 3. Create a PgTyped `config.json` file. 36 | 4. Run `npx pgtyped -w -c config.json` to start PgTyped in watch mode. 37 | 38 | More info on getting started can be found in the [Getting Started](https://pgtyped.dev/docs/getting-started) page. 39 | You can also refer to the [example app](./packages/example/README.md) for a preconfigured example. 40 | 41 | ### Example 42 | 43 | Lets save some queries in `books.sql`: 44 | 45 | ```sql 46 | /* @name FindBookById */ 47 | SELECT * FROM books WHERE id = :bookId; 48 | ``` 49 | 50 | PgTyped parses the SQL file, extracting all queries and generating strictly typed TS queries in `books.queries.ts`: 51 | 52 | ```ts 53 | /** Types generated for queries found in "books.sql" */ 54 | 55 | //... 56 | 57 | /** 'FindBookById' parameters type */ 58 | export interface IFindBookByIdParams { 59 | bookId: number | null; 60 | } 61 | 62 | /** 'FindBookById' return type */ 63 | export interface IFindBookByIdResult { 64 | id: number; 65 | rank: number | null; 66 | name: string | null; 67 | author_id: number | null; 68 | } 69 | 70 | /** 71 | * Query generated from SQL: 72 | * SELECT * FROM books WHERE id = :bookId 73 | */ 74 | export const findBookById = new PreparedQuery< 75 | IFindBookByIdParams, 76 | IFindBookByIdResult 77 | >(...); 78 | ``` 79 | 80 | Query `findBookById` is now statically typed, with types inferred from the PostgreSQL schema. 81 | This generated query can be imported and executed as follows: 82 | 83 | ```ts 84 | import { Client } from 'pg'; 85 | import { findBookById } from './books.queries'; 86 | 87 | export const client = new Client({ 88 | host: 'localhost', 89 | user: 'test', 90 | password: 'example', 91 | database: 'test', 92 | }); 93 | 94 | async function main() { 95 | await client.connect(); 96 | const books = await findBookById.run( 97 | { 98 | bookId: 5, 99 | }, 100 | client, 101 | ); 102 | console.log(`Book name: ${books[0].name}`); 103 | await client.end(); 104 | } 105 | 106 | main(); 107 | ``` 108 | 109 | ### Resources 110 | 111 | 1. [Configuring pgTyped](https://pgtyped.dev/docs/cli) 112 | 2. [Writing queries in SQL files](https://pgtyped.dev/docs/sql-file-intro) 113 | 3. [Advanced queries and parameter expansions in SQL files](https://pgtyped.dev/docs/sql-file) 114 | 4. [Writing queries in TS files](https://pgtyped.dev/docs/ts-file-intro) 115 | 5. [Advanced queries and parameter expansions in TS files](https://pgtyped.dev/docs/ts-file) 116 | 117 | ### Project state: 118 | 119 | This project is being actively developed and its APIs might change. 120 | All issue reports, feature requests and PRs appreciated. 121 | 122 | ### License 123 | 124 | [MIT](https://github.com/adelsz/pgtyped/tree/master/LICENSE) 125 | 126 | Copyright (c) 2019-present, Adel Salakh 127 | -------------------------------------------------------------------------------- /RESCRIPT.md: -------------------------------------------------------------------------------- 1 | # pgtyped-rescript 2 | 3 | This small readme focuses on the differences between regular `pgtyped` and this fork that is compatible with ReScript. 4 | 5 | ## Differences to regular `pgtyped` 6 | 7 | - It outputs ReScript instead of TypeScript. 8 | 9 | Everything else should work pretty much the same as stock `pgtyped`. 10 | 11 | ## Getting started 12 | 13 | Make sure you have ReScript `v11.1`, and [ReScript Core](https://github.com/rescript-association/rescript-core) (plus `RescriptCore` opened globally). 14 | 15 | 1. `npm install -D pgtyped-rescript rescript-embed-lang` (install `rescript-embed-lang` if you want to use the SQL-in-ReScript mode) 16 | 2. `npm install @pgtyped/runtime pg rescript @rescript/core` (`@pgtyped/runtime` and `pg` are the only required runtime dependencies of pgtyped) 17 | 3. Create a PgTyped `pgtyped.config.json` file. 18 | 4. Run `npx pgtyped-rescript -w -c pgtyped.config.json` to start PgTyped in watch mode. 19 | 20 | ### Example of setting up and running `pgtyped-rescript` 21 | 22 | Here's a sample `pgtyped.config.json` file: 23 | 24 | ```json 25 | { 26 | "transforms": [ 27 | { 28 | "mode": "sql", 29 | "include": "**/*.sql", 30 | "emitTemplate": "{{dir}}/{{name}}__sql.res" 31 | }, 32 | { 33 | "mode": "res", 34 | "include": "**/*.res", 35 | "emitTemplate": "{{dir}}/{{name}}__sql.res" 36 | } 37 | ], 38 | "srcDir": "./src", 39 | "dbUrl": "postgres://pgtyped:pgtyped@localhost/pgtyped" 40 | } 41 | ``` 42 | 43 | > Notice how we're configuring what we want the generated ReScript files to be named under `emitTemplate`. For SQL-in-ReScript mode, you need to configure the generated file names exactly as above. 44 | 45 | Please refer to the `pgtyped` docs for all configuration options. 46 | 47 | ## Separate SQL files mode 48 | 49 | `pgtyped-rescript` supports writing queries in separate SQL files, as well as embedded directly in ReScript source code. Below details the separate SQL files approach: 50 | 51 | Create a SQL file anywhere in `src`. We call this one `books.sql`. Add your queries, together with `@name` comments naming them uniquely within the current file: 52 | 53 | ```sql 54 | /* @name findBookById */ 55 | SELECT * FROM books WHERE id = :id!; 56 | ``` 57 | 58 | After running `npx pgtyped-rescript -c pgtyped.config.json` we should get a `books__sql.res` file, with a module `FindBookById` with various functions for executing the query. Here's a full example of how we can connect to a database, and use that generated function to query it: 59 | 60 | ```rescript 61 | open PgTyped 62 | 63 | external env: {..} = "process.env" 64 | 65 | let dbConfig = { 66 | Pg.Client.host: env["PGHOST"]->Option.getWithDefault("127.0.0.1"), 67 | user: env["PGUSER"]->Option.getWithDefault("pgtyped"), 68 | password: env["PGPASSWORD"]->Option.getWithDefault("pgtyped"), 69 | database: env["PGDATABASE"]->Option.getWithDefault("pgtyped"), 70 | port: env["PGPORT"]->Option.flatMap(port => Int.fromString(port))->Option.getWithDefault(5432), 71 | } 72 | 73 | let client = Pg.Client.make(dbConfig) 74 | 75 | let main = async () => { 76 | await client->Pg.Client.connect 77 | 78 | let res = await client->Books__sql.FindBookById.one({id: 1}) 79 | Console.log(res) 80 | 81 | await client->Pg.Client.end 82 | } 83 | 84 | main()->Promise.done 85 | ``` 86 | 87 | ## SQL-in-ReScript 88 | 89 | Optionally, you can write SQL directly in your ReScript code and have a seamless, fully typed experience. The above example but with SQL-in-ReScript: 90 | 91 | ```rescript 92 | let query = %sql.one(` 93 | SELECT * FROM books WHERE id = :id! 94 | `) 95 | 96 | let res = await client->query({id: 1}) 97 | Console.log(res) 98 | ``` 99 | 100 | Notice that with the `%sql` tags, **there's no requirement to name your queries**. You can still name them if you want, but you don't have to. 101 | 102 | In order for this mode to work, you need one more thing - configure the `rescript-embed-lang` PPX in `rescript.json`: 103 | 104 | ```json 105 | "ppx-flags": ["rescript-embed-lang/ppx"], 106 | ``` 107 | 108 | With that, you should be able to write queries directly in your ReScript source, and with the `watch` mode enabled have a seamless experience with types autogenerated and wired up for you. 109 | 110 | ## API 111 | 112 | ### `PgTyped` 113 | 114 | The package comes with minimal bindings to be able to set up a `pg` client. Please feel free to open issues for anything that's missing. It's also easy to add your own bindings locally by using `@send` and binding them to `PgTyped.Pg.Client.t`, like: 115 | 116 | ```rescript 117 | // Imagine `end` didn't have bindings 118 | @send external end: PgTyped.Pg.Client.t => promise = "end" 119 | 120 | await client->end 121 | ``` 122 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zth/pgtyped-rescript/e8d9c646c856662f663418ab9d12ab54482143f7/demo.gif -------------------------------------------------------------------------------- /docs-new/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs-new/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ npm i 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ npm run start 15 | ``` 16 | 17 | This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ npm run build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | ``` 30 | $ GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs-new/docs/dynamic-queries.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: dynamic-queries 3 | title: Dynamic queries 4 | sidebar_label: Dynamic queries 5 | --- 6 | 7 | pgTyped doesn't support query composition or concatenation, but this doesn't mean you can't create dynamic queries. 8 | Instead of providing non-typesafe query composition, pgTyped forces you to move the dynamic logic into the SQL layer. 9 | 10 | ### Dynamic `WHERE` filters 11 | 12 | A frequently used pattern is a query with an optional filter that selects all rows by default. 13 | This can be achieved using a `IS NULL` construct. 14 | Here is an example of a query with optional `age` and `name` filters: 15 | 16 | ```sql 17 | /* @name GetUsers */ 18 | SELECT * FROM users 19 | WHERE (:name :: TEXT IS NULL OR name = :name) 20 | AND (:age_gt :: INTEGER IS NULL OR age > :age_gt); 21 | ``` 22 | 23 | ### Dynamic `ORDER BY` sorting 24 | 25 | Sorting by a dynamic column is another widely used dynamic query: 26 | 27 | ```sql 28 | /* @name GetAllComments */ 29 | SELECT * FROM book_comments 30 | WHERE id = :id ORDER BY :order_column; 31 | ``` 32 | 33 | Next, if we want to include a dynamic sort order as well: 34 | 35 | ```sql 36 | /* @name GetAllUsers */ 37 | SELECT * FROM users 38 | ORDER BY (CASE WHEN :asc = true THEN :sort_column END) ASC, :sort_column DESC; 39 | ``` 40 | 41 | ### Advanced dynamic queries 42 | 43 | More complicated dynamic queries can be built similarly to the above two. 44 | Note that highly dynamic SQL queries can lead to worse DB execution times, so sometimes it is worth to split a complex query into multiple independent ones. 45 | -------------------------------------------------------------------------------- /docs-new/docs/faq.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zth/pgtyped-rescript/e8d9c646c856662f663418ab9d12ab54482143f7/docs-new/docs/faq.md -------------------------------------------------------------------------------- /docs-new/docs/features.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: features 3 | title: Features 4 | sidebar_label: Features 5 | --- 6 | 7 | - **Typesafe SQL** - Automatically generate TS types for parameters/results of SQL queries of any complexity. 8 | - **SQL file support** - Extract queries from both SQL and TS files. 9 | - **Watch mode** - Generate query types as you write them. 10 | - **Interpolation helpers** - Useful parameter interpolation helpers for arrays and objects. 11 | - **Single source of types** - No need to define your DB schema in TypeScript, your running DB is the live source of type data. 12 | - **Prevents SQL injections** - PgTyped doesn't do explicit parameter substitution. Instead, queries and parameters are sent separately to the DB driver, allowing parameter substitution to be safely done by the PostgreSQL server. 13 | - **ESM first** - PgTyped is written in TypeScript and uses ESM modules. The runtime and generated code is also ESM, but CommonJS is also supported. 14 | -------------------------------------------------------------------------------- /docs-new/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getting-started 3 | title: Getting Started 4 | sidebar_label: Getting Started 5 | --- 6 | 7 | ### Installation 8 | 9 | 1. `npm install -D @pgtyped/cli typescript` (typescript is a required peer dependency for pgtyped) 10 | 2. `npm install @pgtyped/runtime` (runtime is the only required runtime dependency for pgtyped) 11 | 2. Create a PgTyped `config.json` file. 12 | 3. Run `npx pgtyped -w -c config.json` to start PgTyped in watch mode. 13 | 14 | ### Configuration 15 | 16 | PgTyped requires a `config.json` file to run, a basic config file looks like this: 17 | 18 | ```json title="config.json" 19 | { 20 | "transforms": [ 21 | { 22 | "mode": "sql", 23 | "include": "**/*.sql", 24 | "emitTemplate": "{{dir}}/{{name}}.queries.ts" 25 | } 26 | ], 27 | "srcDir": "./src/", 28 | "failOnError": false, 29 | "camelCaseColumnNames": false, 30 | "db": { 31 | "host": "db", 32 | "user": "test", 33 | "dbName": "test", 34 | "password": "example" 35 | } 36 | } 37 | ``` 38 | 39 | Refer to the [CLI page](cli) for more info on the config file, available CLI flags and environment variables. 40 | 41 | :::note 42 | If you are having trouble configuring PgTyped, you can refer to the [example app](https://github.com/adelsz/pgtyped/tree/master/packages/example) for a preconfigured example. 43 | ::: 44 | -------------------------------------------------------------------------------- /docs-new/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: intro 3 | title: Overview 4 | sidebar_label: Overview 5 | slug: / 6 | --- 7 | 8 | PgTyped makes it possible to use raw SQL in TypeScript with guaranteed type-safety. 9 | No need to map or translate your DB schema to TypeScript, PgTyped automatically generates types and interfaces for your SQL queries by using your running Postgres database as the source of type information. 10 | 11 | ## Project goals 12 | 13 | - **Smooth developer experience** - Provide a smooth and reliable development experience for engineers that want to use raw SQL queries. 14 | - **Static typing** - SQL queries are validated and fully usable by the typechecker. 15 | - **No magic** - PgTyped is not a query builder or an ORM. 16 | -------------------------------------------------------------------------------- /docs-new/docs/sql-file-intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: sql-file-intro 3 | title: Queries in SQL files 4 | sidebar_label: Queries in SQL files 5 | --- 6 | 7 | Having installed and configured PgTyped it is now time to write some queries. 8 | 9 | Lets create our first query in `books/queries.sql`: 10 | 11 | ```sql title="books/queries.sql" 12 | /* @name FindBookById */ 13 | SELECT * FROM books WHERE id = :bookId; 14 | ``` 15 | 16 | Notice the comment above the SQL query. PgTyped uses such comments to give generated query functions meaningful names. 17 | 18 | If PgTyped is running in watch mode, it will automatically parse the SQL file on each change, extracting all queries and generating strictly typed TS queries in `books/queries.ts`: 19 | 20 | ```ts title="books/queries.ts" 21 | /** Types generated for queries found in "src/books/queries.sql" */ 22 | 23 | //... 24 | 25 | /** 'FindBookById' parameters type */ 26 | export interface IFindBookByIdParams { 27 | bookId: number | null; 28 | } 29 | 30 | /** 'FindBookById' return type */ 31 | export interface IFindBookByIdResult { 32 | id: number; 33 | rank: number | null; 34 | name: string | null; 35 | author_id: number | null; 36 | } 37 | 38 | /** 39 | * Query generated from SQL: 40 | * SELECT * FROM books WHERE id = :commentId 41 | */ 42 | export const findBookById = new PreparedQuery< 43 | IFindBookByIdParams, 44 | IFindBookByIdResult 45 | >(...); 46 | ``` 47 | 48 | Query `findBookById` is now statically typed, with types inferred from the PostgreSQL schema. 49 | This generated query can be imported and executed as follows: 50 | 51 | ```ts title="index.ts" {13} 52 | import { Client } from 'pg'; 53 | import { findBookById } from './src/books/queries'; 54 | 55 | export const client = new Client({ 56 | host: 'localhost', 57 | user: 'test', 58 | password: 'example', 59 | database: 'test', 60 | }); 61 | 62 | async function main() { 63 | await client.connect(); 64 | const books = await findBookById.run( 65 | { 66 | bookId: 42, 67 | }, 68 | client, 69 | ); 70 | console.log(`Book name: ${books[0].name}`); 71 | await client.end(); 72 | } 73 | 74 | main(); 75 | ``` 76 | 77 | For more information on writing queries in SQL files check out the [Annotated SQL](sql-file) guide. 78 | -------------------------------------------------------------------------------- /docs-new/docs/ts-file-intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: ts-file-intro 3 | title: SQL-in-TS 4 | sidebar_label: Queries in TS files 5 | --- 6 | 7 | It sometimes makes sense to inline your queries instead of collecting them in separate SQL files. 8 | PgTyped supports inlined queries using the `sql` template literal. 9 | To see how that works lets write some queries in `users/queries.ts`: 10 | 11 | ```ts title="users/queries.ts" 12 | import { sql } from '@pgtyped/runtime'; 13 | import { ISelectUserIdsQuery } from './queries.types.ts'; 14 | 15 | export const selectUserIds = sql< 16 | ISelectUserIdsQuery 17 | >`select id, age from users where id = $id and age = $age`; 18 | ``` 19 | 20 | PgTyped parses your TS files, scanning them for `sql` queries and generating corresponding TS interfaces in `users/queries.types.ts`: 21 | 22 | ```ts title="users/queries.types.ts" 23 | /** Types generated for queries found in "users/queries.ts" */ 24 | 25 | /** 'selectUserIds' query type */ 26 | export interface ISelectUserIdsQuery { 27 | params: ISelectUserIdsParams; 28 | result: ISelectUserIdsResult; 29 | } 30 | 31 | /** 'selectUserIds' parameters type */ 32 | export interface ISelectUserIdsParams { 33 | id: string | null; 34 | age: number | null; 35 | } 36 | 37 | /** 'selectUserIds' return type */ 38 | export interface ISelectUserIdsResult { 39 | id: string; 40 | /** Age (in years) */ 41 | age: number | null; 42 | } 43 | ``` 44 | 45 | We can now pass the `ISelectUserIdsQuery` as a generic parameter to our query in `users/queries.ts`: 46 | 47 | ```ts title="users/queries.ts" 48 | import { sql } from '@pgtyped/runtime'; 49 | import { ISelectUserIdsQuery } from './queries.types.ts'; 50 | 51 | export const selectUserIds = sql< 52 | ISelectUserIdsQuery 53 | >`select id, age from users where id = $id and age = $age`; 54 | 55 | const users = await selectUserIds.run( 56 | { 57 | id: 'some-user-id', 58 | age: 34, 59 | }, 60 | connection, 61 | ); 62 | 63 | console.log(users[0]); 64 | ``` 65 | 66 | Note that for the `age` column in the result PgTyped has also translated a [Postgres column comment](https://www.postgresql.org/docs/current/sql-comment.html) (`COMMENT ON COLUMN`) to a [TSDoc](https://tsdoc.org/)-style comment. This will appear as a tooltip in your editor if you inspect the relevant property. 67 | 68 | For more information on writing queries in TS files checkout the [SQL-in-TS](ts-file) guide. 69 | -------------------------------------------------------------------------------- /docs-new/docs/ts-file.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: ts-file 3 | title: Typescript files 4 | sidebar_label: Typescript files 5 | --- 6 | 7 | PgTyped also supports parsing queries from TS files. 8 | Such queries must be tagged with an `sql` template literal, like this: 9 | 10 | ```ts 11 | import { sql } from '@pgtyped/runtime'; 12 | 13 | const getUsersWithComments = sql` 14 | SELECT u.* FROM users u 15 | INNER JOIN book_comments bc ON u.id = bc.user_id 16 | GROUP BY u.id 17 | HAVING count(bc.id) > $minCommentCount;`; 18 | ``` 19 | 20 | PgTyped will then scan your project for such `sql` tags and generate types for each query, saving the types in a `filename.types.ts` file. 21 | Once the type files have been generated you can import them to type your query: 22 | 23 | ```ts 24 | import { sql } from '@pgtyped/runtime'; 25 | import { IGetUsersWithCommentsQuery } from './sample.types'; 26 | 27 | const getUsersWithComments = sql` 28 | SELECT u.* FROM users u 29 | INNER JOIN book_comments bc ON u.id = bc.user_id 30 | GROUP BY u.id 31 | HAVING count(bc.id) > $minCommentCount;`; 32 | 33 | const result = await getUsersWithComments.run({ minCommentCount: 12 }, client); 34 | ``` 35 | 36 | # Expansions 37 | 38 | Template literals also support parameter expansions. 39 | Here is how a typical insert query looks like using SQL-in-TS syntax: 40 | 41 | ```ts 42 | const query = sql`INSERT INTO users (name, age) VALUES $$users(name, age) RETURNING id`; 43 | ``` 44 | 45 | Here `$$users(name, age)` is a parameter expansion. 46 | 47 | ## Expansions in SQL-in-TS queries 48 | 49 | ### Array spread 50 | 51 | The array spread expansion allows to pass an array of scalars as parameter. 52 | 53 | #### Syntax: 54 | 55 | ```ts 56 | $$paramName; 57 | ``` 58 | 59 | #### Example: 60 | 61 | ```ts title="Query code:" 62 | const query = sql`SELECT FROM users where age in $$ages`; 63 | 64 | const parameters = { ages: [25, 30, 35] }; 65 | 66 | query.run(parameters, connection); 67 | ``` 68 | 69 | ```sql title="Resulting query:" 70 | -- Bindings: [25, 30, 35] 71 | SELECT FROM users WHERE age in (25, 30, 35); 72 | ``` 73 | 74 | ### Object pick 75 | 76 | The object pick expansion allows to pass an object as a parameter. 77 | 78 | #### Syntax: 79 | 80 | ``` 81 | $user(name, age) 82 | ``` 83 | 84 | #### Example: 85 | 86 | ```ts title="Query code:" 87 | const query = sql< 88 | IQueryType 89 | >`INSERT INTO users (name, age) VALUES $user(name, age) RETURNING id`; 90 | 91 | const parameters = { user: { name: 'Rob', age: 56 } }; 92 | 93 | query.run(parameters, connection); 94 | ``` 95 | 96 | ```sql title="Resulting query:" 97 | -- Bindings: ['Rob', 56] 98 | INSERT INTO users (name, age) VALUES ($1, $2) RETURNING id; 99 | ``` 100 | 101 | ### Array spread and pick 102 | 103 | The array spread-and-pick expansion allows to pass an array of objects as a parameter. 104 | 105 | #### Syntax: 106 | 107 | ``` 108 | $$user(name, age) 109 | ``` 110 | 111 | #### Example: 112 | 113 | ```ts 114 | const query = sql`INSERT INTO users (name, age) VALUES $$users(name, age) RETURNING id`; 115 | 116 | const parameters = { 117 | users: [ 118 | { name: 'Rob', age: 56 }, 119 | { name: 'Tom', age: 45 }, 120 | ], 121 | }; 122 | 123 | query.run(parameters, connection); 124 | ``` 125 | 126 | ```sql title="Resulting query:" 127 | -- Bindings: ['Rob', 56, 'Tom', 45] 128 | INSERT INTO users (name, age) VALUES ($1, $2), ($3, $4) RETURNING id; 129 | ``` 130 | 131 | ## Parameter type reference 132 | 133 | | Expansion | Syntax | Parameter Type | 134 | | --------------------- | --------------------------- | ---------------------------------------------------------- | 135 | | Scalar parameter | `$paramName` | `paramName: ParamType` | 136 | | Object pick | `$paramName(name, author)` | `paramName: { name: NameType, author: AuthorType }` | 137 | | Array spread | `$$paramName` | `paramName: Array` | 138 | | Array pick and spread | `$$paramName(name, author)` | `paramName: Array<{ name: NameType, author: AuthorType }>` | 139 | 140 | ## Substitution reference 141 | 142 | | Expansion | Query in TS | Query with substituted parameter | 143 | |-----------------------|------------------------------|-----------------------------------| 144 | | Simple parameter | `$parameter` | `$1` | 145 | | Object pick | `$object(prop1, prop2)` | `($1, $2)` | 146 | | Array spread | `$$array` | `($1, $2, $3)` | 147 | | Array pick and spread | `$$objectArray(prop1, prop2)`| `($1, $2), ($3, $4), ($5, $6)` | 148 | -------------------------------------------------------------------------------- /docs-new/docs/typing.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: typing 3 | title: Typing 4 | sidebar_label: Typing 5 | --- 6 | 7 | `pgtyped` allows you to override built-in types by providing custom TS types or by importing your own. 8 | 9 | ### Changing the types returned by Postgres 10 | 11 | You can customise the way Postgres responses are parsed very simply in your application: 12 | 13 | ```ts 14 | import { types } from 'pg' 15 | 16 | // DATE are now returned as string instead of Date objects 17 | types.setTypeParser(types.builtins.DATE, (val: string) => val); 18 | 19 | // INT8 (bigint) are now returned as BigInt instead of string 20 | types.setTypeParser(types.builtins.INT8, (val: string) => BigInt(val)); 21 | 22 | // DECIMAL and other precision types are now returned as number instead of string 23 | types.setTypeParser(types.builtins.NUMERIC, (val: string) => Number(val)); 24 | ``` 25 | 26 | This is part of the [`pg` lib](https://github.com/brianc/node-pg-types) and has nothing to do with `pgtyped`. 27 | For `pgtyped` to be aware of those custom parsers, you need to indicate those changes in the config file. 28 | 29 | ### Overriding default mapping 30 | 31 | In the config file you can override the default type mapping: 32 | ```json 33 | { 34 | "typesOverrides": { 35 | "date": "string", 36 | "int8": "BigInt", 37 | "numeric": "number" 38 | } 39 | } 40 | ``` 41 | 42 | You can also specify imported types from your project or from another npm package: 43 | ```json 44 | { 45 | "typesOverrides": { 46 | "timestamptz": "dayjs#Dayjs", // import { Dayjs } from 'dayjs'; 47 | "money": "my-package#Foo as MyType", // import { Foo as MyType } from 'my-package'; 48 | "char": "my-package as MyType", // import MyType from 'my-package'; 49 | "numeric": "./path/to/file.js#MyCustomType", // import { MyCustomType } from './path/to/file.js'; 50 | "float": "./path/to/file#MyCustomType as Alias", // import { MyCustomType as Alias } from './path/to/file'; 51 | "smallint": "../myFile as MyType" // import MyType from '../myFile'; 52 | } 53 | } 54 | ``` 55 | 56 | All relative paths must be relative to the root of your project. 57 | 58 | ### Different types for parameters and return type 59 | 60 | Query results are always parsed the same way, for instance a Postgres `DATE` will always be parsed as a javascript `Date` 61 | (assuming you did not add a custom parser). 62 | 63 | ```sql 64 | SELECT date_of_birth FROM users; 65 | ``` 66 | 67 | Here `date_of_birth` should be typed as a `Date`. But query parameters can support multiple types, for instance a `DATE` 68 | can be compared with a javascript `Date` or with a `string`. 69 | 70 | ```sql 71 | SELECT id FROM users WHERE date_of_birth = :dateOfBirth; 72 | ``` 73 | 74 | Here `dateOfBirth` should be typed `string | Date` as it can receive either. You can specify different types in the config file: 75 | ```json 76 | { 77 | "typesOverrides": { 78 | "date": { 79 | "parameter": "string | Date", 80 | "return": "Date" 81 | } 82 | } 83 | } 84 | ``` 85 | 86 | ### Default mapping 87 | The default mapping is as follows: 88 | 89 | ```ts 90 | type Json = null | boolean | number | string | Json[] | { [key: string]: Json } 91 | 92 | type DefaultMapping = { 93 | // Integer types 94 | 'int2': number 95 | 'int4': number 96 | 'int8': string 97 | 'smallint': number 98 | 'int': number 99 | 'bigint': string 100 | 101 | // Precision types 102 | 'real': number 103 | 'float4': number 104 | 'float': number 105 | 'float8': number 106 | 'numeric': string 107 | 'decimal': string 108 | 109 | // Serial types 110 | 'smallserial': number 111 | 'serial': number 112 | 'bigserial': string 113 | 114 | // Common string types 115 | 'uuid': string 116 | 'text': string 117 | 'varchar': string 118 | 'char': string 119 | 'bpchar': string 120 | 'citext': string 121 | 'name': string 122 | 123 | // Bool types 124 | 'bit': boolean 125 | 'bool': boolean 126 | 'boolean': boolean 127 | 128 | // Dates and times 129 | 'date': Date 130 | 'timestamp': Date 131 | 'timestamptz': Date 132 | 'time': Date 133 | 'timetz': Date 134 | 'interval': string 135 | 136 | // Network address types 137 | 'inet': string 138 | 'cidr': string 139 | 'macaddr': string 140 | 'macaddr8': string 141 | 142 | // Extra types 143 | 'money': string 144 | 'tsvector': string 145 | 'void': undefined, 146 | 147 | // JSON types 148 | 'json': Json, 149 | 'jsonb': Json, 150 | 151 | // Bytes 152 | 'bytea': Buffer, 153 | } 154 | ``` 155 | -------------------------------------------------------------------------------- /docs-new/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'PgTyped', 3 | tagline: 'Typesafe SQL in TypeScript', 4 | url: 'https://pgtyped.dev', 5 | baseUrl: '/', 6 | favicon: 'img/favicon.ico', 7 | organizationName: 'adelsz', // Usually your GitHub org/user name. 8 | projectName: 'pgtyped', // Usually your repo name. 9 | themeConfig: { 10 | docs: { 11 | sidebar: { 12 | hideable: true, 13 | }, 14 | }, 15 | navbar: { 16 | logo: { 17 | alt: 'pgtyped', 18 | src: 'img/logo.svg', 19 | }, 20 | items: [ 21 | { 22 | to: 'docs/', 23 | activeBasePath: 'docs', 24 | label: 'Docs', 25 | position: 'left', 26 | }, 27 | { 28 | href: 'https://github.com/adelsz/pgtyped', 29 | label: 'GitHub', 30 | position: 'right', 31 | }, 32 | ], 33 | }, 34 | footer: { 35 | style: 'dark', 36 | copyright: `Copyright © ${new Date().getFullYear()} Adel Salakh. Built with Docusaurus.`, 37 | }, 38 | }, 39 | presets: [ 40 | [ 41 | '@docusaurus/preset-classic', 42 | { 43 | docs: { 44 | sidebarPath: require.resolve('./sidebars.js'), 45 | // Please change this to your repo. 46 | editUrl: 'https://github.com/adelsz/pgtyped/edit/master/docs-new/', 47 | }, 48 | gtag: { 49 | trackingID: 'G-M3YNPCWP14', 50 | }, 51 | theme: { 52 | customCss: require.resolve('./src/css/custom.css'), 53 | }, 54 | }, 55 | ], 56 | ], 57 | }; 58 | -------------------------------------------------------------------------------- /docs-new/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docsx", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "docusaurus start", 7 | "build": "docusaurus build", 8 | "swizzle": "docusaurus swizzle", 9 | "deploy": "docusaurus deploy" 10 | }, 11 | "dependencies": { 12 | "@docusaurus/core": "2.3.1", 13 | "@docusaurus/preset-classic": "2.3.1", 14 | "classnames": "2.3.1", 15 | "react": "^17.0.0", 16 | "react-dom": "^17.0.0" 17 | }, 18 | "browserslist": { 19 | "production": [ 20 | ">0.2%", 21 | "not dead", 22 | "not op_mini all" 23 | ], 24 | "development": [ 25 | "last 1 chrome version", 26 | "last 1 firefox version", 27 | "last 1 safari version" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /docs-new/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | someSidebar: [ 3 | { 4 | type: 'category', 5 | label: 'PgTyped', 6 | items: [ 7 | 'intro', 8 | 'features', 9 | 'getting-started', 10 | 'sql-file-intro', 11 | 'ts-file-intro', 12 | ], 13 | }, 14 | { 15 | type: 'category', 16 | label: 'Queries', 17 | items: ['sql-file', 'ts-file', 'dynamic-queries'], 18 | }, 19 | 'typing', 20 | 'cli', 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /docs-new/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #28a0bd; 11 | --ifm-color-primary-dark: rgb(33, 175, 144); 12 | --ifm-color-primary-darker: rgb(31, 165, 136); 13 | --ifm-color-primary-darkest: rgb(26, 136, 112); 14 | --ifm-color-primary-light: rgb(70, 203, 174); 15 | --ifm-color-primary-lighter: rgb(102, 212, 189); 16 | --ifm-color-primary-lightest: rgb(146, 224, 208); 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | .docusaurus-highlight-code-line { 21 | background-color: rgb(72, 77, 91); 22 | display: block; 23 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 24 | padding: 0 var(--ifm-pre-padding); 25 | } 26 | -------------------------------------------------------------------------------- /docs-new/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import Layout from '@theme/Layout'; 4 | import Link from '@docusaurus/Link'; 5 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 6 | import useBaseUrl from '@docusaurus/useBaseUrl'; 7 | import styles from './styles.module.css'; 8 | 9 | const features = [ 10 | { 11 | title: <>Typesafety, 12 | imageUrl: 'img/typesafety.svg', 13 | description: ( 14 | <> 15 | Pgtyped generates TS types for parameters and results of SQL queries of 16 | any complexity. 17 | 18 | ), 19 | }, 20 | { 21 | title: <>Parse SQL and TS files, 22 | imageUrl: 'img/multifile.svg', 23 | description: ( 24 | <> 25 | Queries can be written in SQL files together with useful parameter 26 | annotations. In Typescript files, queries can be defined using a{' '} 27 | sql template string literal. 28 | 29 | ), 30 | }, 31 | { 32 | title: <>Prevent SQL injections, 33 | imageUrl: 'img/integrity.svg', 34 | description: ( 35 | <> 36 | PgTyped prevents SQL injections by separately sending queries and 37 | parameters to the DB for execution. This allows parameter substitution 38 | to be safely done by the PostgreSQL server 39 | 40 | ), 41 | }, 42 | ]; 43 | 44 | function Feature({ imageUrl, title, description }) { 45 | const imgUrl = useBaseUrl(imageUrl); 46 | return ( 47 |
48 | {imgUrl && ( 49 |
50 | {title} 51 |
52 | )} 53 |

{title}

54 |

{description}

55 |
56 | ); 57 | } 58 | 59 | function Home() { 60 | const context = useDocusaurusContext(); 61 | const { siteConfig = {} } = context; 62 | return ( 63 | 67 |
68 |
69 |

{siteConfig.title}

70 |

{siteConfig.tagline}

71 |
72 | 79 | Get Started 80 | 81 |
82 |
83 |
84 |
85 | {features && features.length > 0 && ( 86 |
87 |
88 |
89 | {features.map((props, idx) => ( 90 | 91 | ))} 92 |
93 |
94 |
95 | )} 96 |
97 |
98 | ); 99 | } 100 | 101 | export default Home; 102 | -------------------------------------------------------------------------------- /docs-new/src/pages/styles.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | 15 | @media screen and (max-width: 966px) { 16 | .heroBanner { 17 | padding: 2rem; 18 | } 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | 27 | .features { 28 | display: flex; 29 | align-items: center; 30 | padding: 2rem 0; 31 | width: 100%; 32 | } 33 | 34 | .featureImage { 35 | height: 200px; 36 | width: 200px; 37 | } 38 | -------------------------------------------------------------------------------- /docs-new/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zth/pgtyped-rescript/e8d9c646c856662f663418ab9d12ab54482143f7/docs-new/static/img/favicon.ico -------------------------------------------------------------------------------- /docs-new/static/img/integrity.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs-new/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zth/pgtyped-rescript/e8d9c646c856662f663418ab9d12ab54482143f7/header.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | snapshotFormat: { 5 | escapeString: true, 6 | printBasicPrototype: true, 7 | }, 8 | roots: ['src'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | useESM: true, 17 | }, 18 | ], 19 | }, 20 | preset: 'ts-jest/presets/default-esm', 21 | testRegex: '\\.test\\.tsx?$', 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "version": "2.2.1" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pgtyped", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "author": "Adel Salakh", 6 | "license": "MIT", 7 | "scripts": { 8 | "clean": "rm -r packages/*/lib", 9 | "build": "lerna run build", 10 | "watch": "lerna run --parallel --stream watch -- --preserveWatchOutput", 11 | "lint": "tslint --project tsconfig.json -t verbose", 12 | "lint!": "npm run lint -- --fix", 13 | "test": "npm run lint && lerna run --stream test" 14 | }, 15 | "workspaces": [ 16 | "packages/*" 17 | ], 18 | "devDependencies": { 19 | "@types/jest": "29.5.3", 20 | "@types/node": "^18.11.18", 21 | "jest": "29.6.2", 22 | "lerna": "^7.0.0", 23 | "prettier": "2.8.8", 24 | "ts-jest": "29.1.1", 25 | "ts-node": "10.9.1", 26 | "tslint": "6.1.3", 27 | "tslint-config-prettier": "1.18.0", 28 | "tslint-plugin-prettier": "2.3.0", 29 | "typescript": "5.0.4" 30 | }, 31 | "dependencies": { 32 | "io-ts": "^2.1.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | ## @pgtyped/cli 2 | 3 | This package provides the `pgtyped` CLI. 4 | The `pgtyped` CLI can work in build and watch mode. 5 | 6 | ### Flags: 7 | 8 | The CLI supports two flags: 9 | 10 | - `-c config_file_path.json` to pass the config file path. 11 | - `-w` to start in watch mode. 12 | 13 | Running the CLI: 14 | 15 | ``` 16 | npx pgtyped -w -c config.json 17 | ``` 18 | 19 | ### Env variables: 20 | 21 | PgTyped supports common PostgreSQL environment variables: 22 | 23 | - `PGHOST` 24 | - `PGUSER` 25 | - `PGPASSWORD` 26 | - `PGDATABASE` 27 | - `PGPORT` 28 | 29 | These variables will override values provided in `config.json`. 30 | 31 | ### Config file: 32 | 33 | Config file format (`config.json`): 34 | 35 | ```js 36 | { 37 | // You can specify as many transforms as you want 38 | // Only TS and SQL files (modes) are supported at the moment 39 | "transforms": [ 40 | { 41 | "mode": "sql", // SQL mode 42 | "include": "**/*.sql", // SQL files pattern to scan for queries 43 | "emitTemplate": "{{dir}}/{{name}}.queries.ts" // File name template to save generated files 44 | }, 45 | { 46 | "mode": "ts", // TS mode 47 | "include": "**/action.ts", // TS file pattern to scan for queries 48 | "emitTemplate": "{{dir}}/{{name}}.types.ts" // File name template to save generated files 49 | } 50 | ], 51 | "srcDir": "./src/", // Directory to scan or watch for query files 52 | "failOnError": false, // Whether to fail on a file processing error and abort generation (can be omitted - default is false) 53 | "camelCaseColumnNames": false, // convert to camelCase column names of result interface 54 | "db": { 55 | "dbName": "testdb", // DB name 56 | "user": "user", // DB username 57 | "password": "password", // DB password (optional) 58 | "host": "127.0.0.1" // DB host (optional) 59 | } 60 | } 61 | ``` 62 | 63 | ### Generated files 64 | 65 | By default, PgTyped saves generated files in the same folder as the source files it parses. 66 | This behavior can be customized using the `emitTemplate` config parameter. 67 | In that template, four parameters are available for interpolation: `root`, `dir`, `base`, `name` and `ext`. 68 | For example, when parsing source/query file `/home/user/dir/file.sql`, these parameters are assigned the following values: 69 | 70 | ``` 71 | ┌─────────────────────┬────────────┐ 72 | │ dir │ base │ 73 | ├──────┬ ├──────┬─────┤ 74 | │ root │ │ name │ ext │ 75 | " / home/user/dir / file .sql " 76 | └──────┴──────────────┴──────┴─────┘ 77 | (All spaces in the "" line should be ignored. They are purely for formatting.) 78 | ``` 79 | 80 | --- 81 | 82 | This package is part of the PgTyped project. 83 | Refer to root [README](https://github.com/adelsz/pgtyped) for details. 84 | -------------------------------------------------------------------------------- /packages/cli/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | snapshotFormat: { 5 | escapeString: true, 6 | printBasicPrototype: true, 7 | }, 8 | roots: ['src'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | useESM: true, 17 | }, 18 | ], 19 | }, 20 | preset: 'ts-jest/presets/default-esm', 21 | testRegex: 'rescript\\.test\\.tsx?$', 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pgtyped-rescript", 3 | "version": "2.6.0", 4 | "type": "module", 5 | "main": "lib/index.js", 6 | "exports": { 7 | "./*": { 8 | "import": "./lib/index.js", 9 | "types": "./lib/index.d.ts" 10 | } 11 | }, 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/adelsz/pgtyped.git" 16 | }, 17 | "files": [ 18 | "rescript.json", 19 | "lib", 20 | "src/res" 21 | ], 22 | "engines": { 23 | "node": ">=14.16" 24 | }, 25 | "homepage": "https://github.com/adelsz/pgtyped", 26 | "publishConfig": { 27 | "access": "public" 28 | }, 29 | "scripts": { 30 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", 31 | "build": "rescript && tsc", 32 | "check": "tsc --noEmit", 33 | "watch": "tsc --watch --preserveWatchOutput" 34 | }, 35 | "bin": { 36 | "pgtyped-rescript": "lib/index.js" 37 | }, 38 | "dependencies": { 39 | "@pgtyped/parser": "^2.1.0", 40 | "@pgtyped/wire": "^2.2.0", 41 | "@rescript/tools": "0.6.4", 42 | "@rescript/core": "1.6.0", 43 | "camel-case": "^4.1.1", 44 | "chalk": "^4.0.0", 45 | "chokidar": "^3.3.1", 46 | "debug": "^4.1.1", 47 | "fp-ts": "^2.5.3", 48 | "fs-extra": "^11.0.0", 49 | "glob": "^10.0.0", 50 | "io-ts": "^2.2.20", 51 | "io-ts-reporters": "^2.0.1", 52 | "nunjucks": "3.2.4", 53 | "pascal-case": "^3.1.1", 54 | "pgtyped-rescript-query": "^2.4.0", 55 | "piscina": "^4.0.0", 56 | "tinypool": "^0.7.0", 57 | "ts-parse-database-url": "^1.0.3", 58 | "yargs": "^17.0.1" 59 | }, 60 | "devDependencies": { 61 | "@types/debug": "4.1.8", 62 | "@types/fs-extra": "11.0.1", 63 | "@types/glob": "8.1.0", 64 | "@types/nunjucks": "^3.1.3", 65 | "@types/yargs": "17.0.24", 66 | "rescript": "11.1.0" 67 | }, 68 | "peerDependencies": { 69 | "@rescript/core": ">= 1.3.0", 70 | "rescript": ">= 11.1.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/cli/rescript.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pgtyped-rescript", 3 | "sources": { 4 | "dir": "src/res" 5 | }, 6 | "package-specs": { 7 | "module": "commonjs", 8 | "in-source": true 9 | }, 10 | "suffix": ".js", 11 | "bs-dependencies": ["@rescript/core"], 12 | "bsc-flags": ["-open RescriptCore"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/cli/src/__snapshots__/parseTypescript.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`parser finds string template in correct file 1`] = ` 4 | Object { 5 | "events": Array [], 6 | "queries": Array [ 7 | Object { 8 | "name": "query", 9 | "params": Array [], 10 | "text": "select id, name, age from users", 11 | }, 12 | ], 13 | } 14 | `; 15 | 16 | exports[`parser finds string template in incorrect file 1`] = ` 17 | Object { 18 | "events": Array [], 19 | "queries": Array [ 20 | Object { 21 | "name": "query", 22 | "params": Array [], 23 | "text": "select id, name, age from users", 24 | }, 25 | ], 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /packages/cli/src/declareImport.test.ts: -------------------------------------------------------------------------------- 1 | import { declareImport } from './types.js'; 2 | 3 | test('default', () => { 4 | expect( 5 | declareImport( 6 | [{ name: 'Alias', from: 'package', aliasOf: 'default' }], 7 | './', 8 | ), 9 | ).toBe("import Alias from 'package';\n"); 10 | }); 11 | 12 | test('named', () => { 13 | expect( 14 | declareImport( 15 | [ 16 | { name: 'Foo', from: 'package' }, 17 | { name: 'Bar', from: 'package' }, 18 | { name: 'Baz', from: 'package', aliasOf: 'Baz' }, 19 | ], 20 | './', 21 | ), 22 | ).toBe("import { Foo, Bar, Baz } from 'package';\n"); 23 | }); 24 | 25 | test('named aliased', () => { 26 | expect( 27 | declareImport( 28 | [ 29 | { name: 'Alias1', from: 'package', aliasOf: 'Foo' }, 30 | { name: 'Alias2', from: 'package', aliasOf: 'Bar' }, 31 | ], 32 | './', 33 | ), 34 | ).toBe("import { Foo as Alias1, Bar as Alias2 } from 'package';\n"); 35 | }); 36 | 37 | test('mix', () => { 38 | expect( 39 | declareImport( 40 | [ 41 | { name: 'Alias', from: 'package', aliasOf: 'default' }, 42 | { name: 'Alias1', from: 'package', aliasOf: 'Foo' }, 43 | { name: 'Bar', from: 'package' }, 44 | { name: 'Alias2', from: 'package', aliasOf: 'Baz' }, 45 | ], 46 | './', 47 | ), 48 | ).toBe( 49 | "import Alias, { Foo as Alias1, Bar, Baz as Alias2 } from 'package';\n", 50 | ); 51 | }); 52 | 53 | describe('relative imports', () => { 54 | test('sub dir', () => { 55 | expect( 56 | declareImport( 57 | [{ name: 'Alias', from: './my/custom/path', aliasOf: 'default' }], 58 | './my/file.ts', 59 | ), 60 | ).toBe("import Alias from './custom/path';\n"); 61 | }); 62 | 63 | test('parent dir', () => { 64 | expect( 65 | declareImport( 66 | [{ name: 'Alias', from: './my/custom/path', aliasOf: 'default' }], 67 | './foo/bar/file.ts', 68 | ), 69 | ).toBe("import Alias from '../../my/custom/path';\n"); 70 | }); 71 | 72 | test('parent parent dir', () => { 73 | expect( 74 | declareImport( 75 | [{ name: 'Alias', from: '../my/custom/path', aliasOf: 'default' }], 76 | './foo/bar/file.ts', 77 | ), 78 | ).toBe("import Alias from '../../../my/custom/path';\n"); 79 | }); 80 | 81 | test('sub dir with extension', () => { 82 | expect( 83 | declareImport( 84 | [{ name: 'Alias', from: './my/custom/path.js', aliasOf: 'default' }], 85 | './my/file.ts', 86 | ), 87 | ).toBe("import Alias from './custom/path.js';\n"); 88 | }); 89 | 90 | test('parent dir with extension', () => { 91 | expect( 92 | declareImport( 93 | [{ name: 'Alias', from: './my/custom/path.ts', aliasOf: 'default' }], 94 | './foo/bar/file.ts', 95 | ), 96 | ).toBe("import Alias from '../../my/custom/path.ts';\n"); 97 | }); 98 | 99 | test('parent parent dir with extension', () => { 100 | expect( 101 | declareImport( 102 | [{ name: 'Alias', from: '../my/custom/path.ts', aliasOf: 'default' }], 103 | './foo/bar/file.ts', 104 | ), 105 | ).toBe("import Alias from '../../../my/custom/path.ts';\n"); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /packages/cli/src/parseRescript.ts: -------------------------------------------------------------------------------- 1 | import { parseSQLFile } from '@pgtyped/parser'; 2 | import { SQLParseResult } from '@pgtyped/parser/lib/loader/sql'; 3 | import cp from 'child_process'; 4 | // @ts-ignore 5 | import { getBinaryPath } from '@rescript/tools/npm/getBinaryPath.js'; 6 | 7 | export function parseCode( 8 | fileContent: string, 9 | fileName: string, 10 | ): SQLParseResult { 11 | if (!fileContent.includes('%sql')) { 12 | return { 13 | queries: [], 14 | events: [], 15 | }; 16 | } 17 | 18 | // Replace with more robust @rescript/tools CLI usage when that package ships linuxarm64 binary. 19 | const content: Array<{ contents: string }> = JSON.parse( 20 | cp 21 | .execFileSync(getBinaryPath(), [ 22 | 'extract-embedded', 23 | ['sql', 'sql.one', 'sql.expectOne', 'sql.many', 'sql.execute'].join( 24 | ',', 25 | ), 26 | fileName, 27 | ]) 28 | .toString(), 29 | ); 30 | 31 | content.reverse(); 32 | 33 | const queries: Array = []; 34 | let unnamedQueriesCount = 0; 35 | 36 | content.forEach((v) => { 37 | let query = v.contents.trim(); 38 | if (!query.endsWith(';')) { 39 | query += ';'; 40 | } 41 | 42 | if (!query.includes('@name')) { 43 | unnamedQueriesCount += 1; 44 | // Handle potentially existing doc comment 45 | if (query.trim().startsWith('/*')) { 46 | const lines = query.split('\n'); 47 | 48 | let comment = `/*\n@name Query${unnamedQueriesCount}\n`; 49 | for (let i = 0; i <= lines.length - 1; i += 1) { 50 | const line = lines[i].trim().replace('/*', ''); 51 | comment += line + '\n'; 52 | if (line.endsWith('*/')) { 53 | query = lines.slice(i + 1).join('\n'); 54 | break; 55 | } 56 | } 57 | query = `${comment}\n${query}`; 58 | } else { 59 | query = `/* @name Query${unnamedQueriesCount} */\n${query}`; 60 | } 61 | } 62 | 63 | queries.push(query); 64 | }); 65 | 66 | const asSql = queries.join('\n\n'); 67 | 68 | const res = parseSQLFile(asSql); 69 | return res; 70 | } 71 | -------------------------------------------------------------------------------- /packages/cli/src/parseTypescript.test.ts: -------------------------------------------------------------------------------- 1 | import { parseCode } from './parseTypescript.js'; 2 | 3 | test('parser finds string template in correct file', () => { 4 | const fileContent = ` 5 | const sql : any = null; 6 | 7 | const query = sql\` 8 | select id, name, age from users; 9 | \`; 10 | `; 11 | 12 | const result = parseCode(fileContent); 13 | expect(result).toMatchSnapshot(); 14 | }); 15 | 16 | test('parser finds string template in incorrect file', () => { 17 | const fileContent = ` 18 | const sql ny =/ null; 19 | 20 | const query = sql\` 21 | select id, name, age from users; 22 | \`; 23 | `; 24 | 25 | const result = parseCode(fileContent); 26 | expect(result).toMatchSnapshot(); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/cli/src/parseTypescript.ts: -------------------------------------------------------------------------------- 1 | const ts: any = {}; 2 | 3 | interface INode { 4 | queryName: string; 5 | queryText: string; 6 | } 7 | 8 | import { parseTSQuery, TSQueryAST, ParseEvent } from '@pgtyped/parser'; 9 | 10 | export type TSParseResult = { queries: TSQueryAST[]; events: ParseEvent[] }; 11 | 12 | export function parseFile(sourceFile: any): TSParseResult { 13 | const foundNodes: INode[] = []; 14 | parseNode(sourceFile); 15 | 16 | function parseNode(node: any) { 17 | if (node.kind === ts.SyntaxKind.TaggedTemplateExpression) { 18 | const queryName = node.parent.getChildren()[0].getText(); 19 | const taggedTemplateNode = node as any; 20 | const tagName = taggedTemplateNode.tag.getText(); 21 | const queryText = taggedTemplateNode.template 22 | .getText() 23 | .replace('\n', '') 24 | .slice(1, -1) 25 | .trim(); 26 | if (tagName === 'sql') { 27 | foundNodes.push({ 28 | queryName, 29 | queryText, 30 | }); 31 | } 32 | } 33 | 34 | ts.forEachChild(node, parseNode); 35 | } 36 | 37 | const queries: TSQueryAST[] = []; 38 | const events: ParseEvent[] = []; 39 | for (const node of foundNodes) { 40 | const { query, events: qEvents } = parseTSQuery( 41 | node.queryText, 42 | node.queryName, 43 | ); 44 | queries.push(query); 45 | events.push(...qEvents); 46 | } 47 | 48 | return { queries, events }; 49 | } 50 | 51 | export const parseCode = (fileContent: string, fileName = 'unnamed.ts') => { 52 | const sourceFile = ts.createSourceFile( 53 | fileName, 54 | fileContent, 55 | ts.ScriptTarget.ES2015, 56 | true, 57 | ); 58 | return parseFile(sourceFile); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/cli/src/res/PgTyped.res: -------------------------------------------------------------------------------- 1 | module Pg = { 2 | // TODO: Improve safety of this? 3 | type pgValue 4 | external toPgValue: 'any => pgValue = "%identity" 5 | 6 | module PgResult = { 7 | type fieldInfo = { 8 | name: string, 9 | dataTypeID: int, 10 | } 11 | 12 | type t<'row> = { 13 | rows: array<'row>, 14 | fields: array, 15 | command: string, 16 | rowCount: Null.t, 17 | } 18 | } 19 | 20 | module Client = { 21 | type t 22 | 23 | type config = { 24 | /** default process.env.PGUSER || process.env.USER*/ user?: string, 25 | /**default process.env.PGPASSWORD*/ password?: string, 26 | /** default process.env.PGHOST*/ host?: string, 27 | /** default process.env.PGPORT*/ port?: int, 28 | /** default process.env.PGDATABASE || user*/ database?: string, 29 | /** e.g. postgres://user:password@host:5432/database*/ connectionString?: string, 30 | /** passed directly to node.TLSSocket, supports all tls.connect options*/ ssl?: unknown, 31 | /** custom type parsers*/ types?: unknown, 32 | /** number of milliseconds before a statement in query will time out, default is no timeout*/ 33 | statement_timeout?: float, 34 | /** number of milliseconds before a query call will timeout, default is no timeout*/ 35 | query_timeout?: float, 36 | /** number of milliseconds a query is allowed to be en lock state before it's cancelled due to lock timeout*/ 37 | lock_timeout?: float, 38 | /** The name of the application that created this Client instance*/ application_name?: string, 39 | /** number of milliseconds to wait for connection, default is no timeout*/ 40 | connectionTimeoutMillis?: float, 41 | /** number of milliseconds before terminating any session with an open idle transaction, default is no timeout*/ 42 | idle_in_transaction_session_timeout?: float, 43 | } 44 | 45 | @unboxed 46 | type pgConfig = Config(config) | ConnectionString(string) 47 | 48 | @module("pg") @new external make: pgConfig => t = "Client" 49 | @send external connect: t => promise = "connect" 50 | @send external end: t => promise = "end" 51 | @send external release: t => promise = "release" 52 | 53 | /** Bind when needed. */ 54 | type typeParsers 55 | 56 | type queryConfig = { 57 | /** the raw query text*/ 58 | text: string, 59 | /** an array of query parameters*/ 60 | values?: array, 61 | /** name of the query - used for prepared statements*/ 62 | name?: string, 63 | /** by default rows come out as a key/value pair for each row*/ 64 | /** pass the string 'array' here to receive rows as an array of values*/ 65 | rowMode?: [#array], 66 | /** custom type parsers just for this query result*/ 67 | types?: typeParsers, 68 | /** TODO: document*/ 69 | queryMode?: string, 70 | } 71 | 72 | @send external queryWithConfig: (t, queryConfig) => promise> = "query" 73 | 74 | @send 75 | external query: (t, string, ~values: array=?) => promise> = "query" 76 | 77 | // TODO: Events 78 | } 79 | 80 | module Pool = { 81 | type t 82 | 83 | type config = { 84 | /** all valid client config options are also valid here 85 | in addition here are the pool specific configuration parameters: 86 | 87 | number of milliseconds to wait before timing out when connecting a new client 88 | by default this is 0 which means no timeout*/ 89 | connectionTimeoutMillis?: float, 90 | /** number of milliseconds a client must sit idle in the pool and not be checked out 91 | before it is disconnected from the backend and discarded 92 | default is 10000 (10 seconds) - set to 0 to disable auto-disconnection of idle clients*/ 93 | idleTimeoutMillis?: float, 94 | /** maximum number of clients the pool should contain 95 | by default this is set to 10.*/ 96 | max?: int, 97 | /**Default behavior is the pool will keep clients open & connected to the backend 98 | until idleTimeoutMillis expire for each client and node will maintain a ref 99 | to the socket on the client, keeping the event loop alive until all clients are closed 100 | after being idle or the pool is manually shutdown with `pool.end()`. 101 | Setting `allowExitOnIdle: true` in the config will allow the node event loop to exit 102 | as soon as all clients in the pool are idle, even if their socket is still open 103 | to the postgres server. This can be handy in scripts & tests 104 | where you don't want to wait for your clients to go idle before your process exits.*/ 105 | allowExitOnIdle?: bool, 106 | } 107 | 108 | @unboxed 109 | type pgConfig = Config(config) | ConnectionString(string) 110 | 111 | @module("pg") @new external make: pgConfig => t = "Pool" 112 | 113 | @send 114 | external query: (t, string, ~values: array=?) => promise> = "query" 115 | @send external connect: t => promise = "connect" 116 | @send external end: t => promise = "end" 117 | 118 | @get external totalCount: t => int = "totalCount" 119 | @get external idleCount: t => int = "idleCount" 120 | @get external waitingCount: t => int = "waitingCount" 121 | 122 | // TODO: Events 123 | } 124 | } 125 | 126 | module IR = { 127 | type t 128 | } 129 | 130 | module PreparedStatement = { 131 | type t<'params, 'result> 132 | 133 | @send 134 | external run: (t<'params, 'result>, 'params, ~client: Pg.Client.t) => promise> = 135 | "run" 136 | } 137 | 138 | type dateOrString = string 139 | -------------------------------------------------------------------------------- /packages/cli/src/rescript.test.ts: -------------------------------------------------------------------------------- 1 | test('test', () => { 2 | expect(true).toBe(true); 3 | }); 4 | -------------------------------------------------------------------------------- /packages/cli/src/rescript.test_disabled.ts: -------------------------------------------------------------------------------- 1 | test('tests are disabled for this module', () => { 2 | expect(true).toBe(true); 3 | }); 4 | -------------------------------------------------------------------------------- /packages/cli/src/stringToType.test.ts: -------------------------------------------------------------------------------- 1 | import { stringToType } from './config.js'; 2 | 3 | export {}; 4 | 5 | test('typescript type', () => { 6 | expect(stringToType('string | boolean')).toEqual({ 7 | name: 'string | boolean', 8 | }); 9 | }); 10 | 11 | test('relative imports should have alias or named import', () => { 12 | expect(() => stringToType('./relative')).toThrow(); 13 | }); 14 | 15 | test('relative import default', () => { 16 | expect(stringToType('./relative as DefaultImport')).toEqual({ 17 | name: 'DefaultImport', 18 | from: './relative', 19 | aliasOf: 'default', 20 | }); 21 | }); 22 | 23 | test('relative named import', () => { 24 | expect(stringToType('./relative#NamedImport')).toEqual({ 25 | name: 'NamedImport', 26 | from: './relative', 27 | }); 28 | }); 29 | 30 | test('relative named import alias', () => { 31 | expect(stringToType('./relative#NamedImport as AliasedImport')).toEqual({ 32 | name: 'AliasedImport', 33 | from: './relative', 34 | aliasOf: 'NamedImport', 35 | }); 36 | }); 37 | 38 | test('relative import default with extension', () => { 39 | expect(stringToType('./relative.ts as DefaultImport')).toEqual({ 40 | name: 'DefaultImport', 41 | from: './relative.ts', 42 | aliasOf: 'default', 43 | }); 44 | }); 45 | 46 | test('relative named import with extension', () => { 47 | expect(stringToType('./relative.js#NamedImport')).toEqual({ 48 | name: 'NamedImport', 49 | from: './relative.js', 50 | }); 51 | }); 52 | 53 | test('relative named import alias with extension', () => { 54 | expect(stringToType('./relative.ts#NamedImport as AliasedImport')).toEqual({ 55 | name: 'AliasedImport', 56 | from: './relative.ts', 57 | aliasOf: 'NamedImport', 58 | }); 59 | }); 60 | 61 | test('named import', () => { 62 | expect(stringToType('my-package#NamedImport')).toEqual({ 63 | name: 'NamedImport', 64 | from: 'my-package', 65 | }); 66 | }); 67 | 68 | test('named import alias', () => { 69 | expect(stringToType('my-package#NamedImport as AliasedImport')).toEqual({ 70 | name: 'AliasedImport', 71 | from: 'my-package', 72 | aliasOf: 'NamedImport', 73 | }); 74 | }); 75 | 76 | test('import alias', () => { 77 | expect(stringToType('my-package as AliasedImport')).toEqual({ 78 | name: 'AliasedImport', 79 | from: 'my-package', 80 | aliasOf: 'default', 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /packages/cli/src/types.test.ts: -------------------------------------------------------------------------------- 1 | import { TypeAllocator, TypeMapping, TypeScope } from './types.js'; 2 | 3 | describe('TypeAllocator', () => { 4 | test('Allows overrides', () => { 5 | const types = new TypeAllocator( 6 | TypeMapping({ 7 | foo: { return: { name: 'bar' }, parameter: { name: 'baz' } }, 8 | }), 9 | ); 10 | expect(types.use('foo', TypeScope.Return)).toEqual('bar'); 11 | expect(types.use('foo', TypeScope.Parameter)).toEqual('baz'); 12 | }); 13 | 14 | // Covers issue #323 15 | test('Uses `Json` when using `JsonArray`', () => { 16 | const types = new TypeAllocator(TypeMapping()); 17 | // `_json` is the type name from PG corresponding to an array of JSON values 18 | types.use('_json', TypeScope.Return); 19 | // The definition of `JsonArray` depends on the definition of `Json`, so we 20 | // expect both to be included 21 | expect(types.types).toMatchObject({ 22 | Json: expect.objectContaining({ name: 'Json' }), 23 | JsonArray: expect.objectContaining({ name: 'JsonArray' }), 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/cli/src/util.ts: -------------------------------------------------------------------------------- 1 | import debugBase from 'debug'; 2 | export const debug = debugBase('pg-typegen'); 3 | -------------------------------------------------------------------------------- /packages/cli/src/worker.ts: -------------------------------------------------------------------------------- 1 | import nun from 'nunjucks'; 2 | import path from 'path'; 3 | import fs from 'fs-extra'; 4 | import { generateDeclarationFile } from './generator.js'; 5 | import { startup } from 'pgtyped-rescript-query'; 6 | import { ParsedConfig, TransformConfig } from './config.js'; 7 | import { AsyncQueue } from '@pgtyped/wire'; 8 | import worker from 'piscina'; 9 | 10 | // disable autoescape as it breaks windows paths 11 | // see https://github.com/adelsz/pgtyped/issues/519 for details 12 | nun.configure({ autoescape: false }); 13 | 14 | let connected = false; 15 | const connection = new AsyncQueue(); 16 | const config: ParsedConfig = worker.workerData; 17 | 18 | export default async function processFile({ 19 | fileName, 20 | transform, 21 | }: { 22 | fileName: string; 23 | transform: TransformConfig; 24 | }): Promise<{ 25 | skipped: boolean; 26 | typeDecsLength: number; 27 | relativePath: string; 28 | }> { 29 | if (!connected) { 30 | await startup(config.db, connection); 31 | connected = true; 32 | } 33 | const ppath = path.parse(fileName); 34 | let decsFileName; 35 | if (transform.emitTemplate) { 36 | decsFileName = nun.renderString(transform.emitTemplate, ppath); 37 | } else { 38 | const suffix = transform.mode === 'res' ? '__sql.res' : '.res'; 39 | decsFileName = path.resolve(ppath.dir, `${ppath.name}${suffix}`); 40 | } 41 | 42 | // last part fixes https://github.com/adelsz/pgtyped/issues/390 43 | const contents = fs.readFileSync(fileName).toString().replace(/\r\n/g, '\n'); 44 | 45 | const { declarationFileContents, typeDecs } = await generateDeclarationFile( 46 | contents, 47 | fileName, 48 | connection, 49 | transform.mode, 50 | config, 51 | decsFileName, 52 | ); 53 | const relativePath = path.relative(process.cwd(), decsFileName); 54 | if (typeDecs.length > 0) { 55 | const oldDeclarationFileContents = (await fs.pathExists(decsFileName)) 56 | ? await fs.readFile(decsFileName, { encoding: 'utf-8' }) 57 | : null; 58 | if (oldDeclarationFileContents !== declarationFileContents) { 59 | await fs.outputFile(decsFileName, declarationFileContents); 60 | return { 61 | skipped: false, 62 | typeDecsLength: typeDecs.length, 63 | relativePath, 64 | }; 65 | } 66 | } 67 | return { 68 | skipped: true, 69 | typeDecsLength: 0, 70 | relativePath, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib/" /* Redirect output structure to the directory. */, 5 | "rootDir": "./src/" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 6 | }, 7 | "exclude": ["lib", "**/*.test.ts", "jest.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib 4 | lib-cjs 5 | !lib-cjs/package.json 6 | -------------------------------------------------------------------------------- /packages/example/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_IMAGE 2 | 3 | FROM $NODE_IMAGE 4 | 5 | RUN echo "Using Node.js version: $(node --version)" 6 | 7 | RUN apk add --update --no-cache postgresql-client git 8 | 9 | ADD scripts/wait-for-postgres-then /usr/local/bin/ 10 | 11 | WORKDIR /app/packages/example 12 | 13 | RUN git config --global --add safe.directory /app 14 | 15 | ENTRYPOINT ["scripts/wait-for-postgres-then"] 16 | 17 | -------------------------------------------------------------------------------- /packages/example/README.md: -------------------------------------------------------------------------------- 1 | ## @pgtyped/example 2 | 3 | This is an example app using `pgtyped`. 4 | Example queries are stored in `src/books/queries.sql`, `src/users/queries.ts` and `src/comments/queries.sql`. 5 | Try starting PgTyped and editing them to see live query type generation. 6 | 7 | ### Usage with your own DB: 8 | 1. `npm install` 9 | 2. Save your config into `config.json` 10 | 2. `npx pgtyped -w -c config.json` 11 | 12 | ### Using the dockerized example setup: 13 | 1. Clone the whole pgtyped monorepo into some directory. 14 | `git clone git@github.com:adelsz/pgtyped.git pgtyped` 15 | 2. `cd pgtyped/packages/example` 16 | 3. `npm install` 17 | 4. `npm run build` 18 | 5. `docker-compose run watch` 19 | 6. Try editing queries in the SQL and TS files and see how PgTyped handles it. 20 | 21 | The dockerized setup isn't required and is included for convenience. 22 | It creates a PostgreSQL DB, loading it with the schema and seed records defined in `sql/schema.sql`. 23 | After that it starts PgTyped in a separate container, connecting it to the DB. 24 | 25 | -------------------------------------------------------------------------------- /packages/example/check-git-diff.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # When running in CI, run git diff to catch any unexpected code changes 4 | 5 | if test -n "$CI"; then 6 | echo "CI run detected." 7 | echo $CI 8 | else 9 | echo "CI run not detected. Skipping git diff check." 10 | exit 0 11 | fi 12 | 13 | if test -z "$(git status --porcelain)"; then 14 | echo "No changes detected." 15 | else 16 | echo "Found uncommitted codebase changes." 17 | echo "This should never happen on a CI run." 18 | echo "If the changes are expected please commit them first." 19 | git status 20 | exit 1 21 | fi 22 | -------------------------------------------------------------------------------- /packages/example/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "transforms": [ 3 | { 4 | "mode": "sql", 5 | "include": "**/*.sql", 6 | "emitTemplate": "{{dir}}/{{name}}__sql.res" 7 | }, 8 | { 9 | "mode": "res", 10 | "include": "**/*.res", 11 | "emitTemplate": "{{dir}}/{{name}}__sql.res" 12 | } 13 | ], 14 | "typesOverrides": { 15 | "date": { 16 | "return": "string" 17 | }, 18 | "int8": "bigint" 19 | }, 20 | "srcDir": "./src/", 21 | "dbUrl": "postgres://postgres:password@localhost/postgres" 22 | } 23 | -------------------------------------------------------------------------------- /packages/example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | 5 | db: 6 | image: postgres:15-alpine 7 | restart: always 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: password 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - ./sql/:/docker-entrypoint-initdb.d 15 | 16 | build: &build 17 | build: 18 | context: . 19 | args: 20 | NODE_IMAGE: "node:${NODE_VERSION:-18}-alpine" 21 | environment: 22 | PGHOST: db 23 | PGUSER: postgres 24 | PGDATABASE: postgres 25 | PGPASSWORD: password 26 | CI: $CI 27 | volumes: 28 | - ../../:/app 29 | working_dir: /app/packages/example 30 | deploy: 31 | restart_policy: 32 | condition: none 33 | depends_on: 34 | - db 35 | command: sh -c "node /app/packages/cli/lib/index.js --config config.json && ./check-git-diff.sh" 36 | 37 | watch: 38 | <<: *build 39 | command: npx pgtyped --watch --config config.json 40 | 41 | test: 42 | <<: *build 43 | depends_on: 44 | - build 45 | command: npx jest rescript.test.js 46 | 47 | test-cjs: 48 | <<: *build 49 | depends_on: 50 | - build 51 | command: npx jest -c jest-cjs.config.ts rescript.test.js 52 | 53 | test-update-snapshots: 54 | <<: *build 55 | depends_on: 56 | - build 57 | command: npx jest rescript.test.js -u 58 | 59 | -------------------------------------------------------------------------------- /packages/example/jest-cjs.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | snapshotFormat: { 5 | escapeString: true, 6 | printBasicPrototype: true, 7 | }, 8 | roots: ['src'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | tsConfig: { 17 | module: 'commonjs', 18 | }, 19 | }, 20 | ], 21 | }, 22 | testRegex: '\\.test\\.tsx?$', 23 | }; 24 | 25 | export default config; 26 | -------------------------------------------------------------------------------- /packages/example/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | snapshotFormat: { 5 | escapeString: true, 6 | printBasicPrototype: true, 7 | }, 8 | roots: ['src'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | useESM: true, 17 | }, 18 | ], 19 | }, 20 | preset: 'ts-jest/presets/default-esm', 21 | testRegex: '\\.test\\.js?$', 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /packages/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pgtyped/example", 3 | "version": "2.2.1", 4 | "description": "A package demonstrating how pgtyped works.", 5 | "author": "Adel Salakh", 6 | "private": true, 7 | "homepage": "", 8 | "license": "MIT", 9 | "exports": "./lib/index.js", 10 | "type": "module", 11 | "directories": { 12 | "lib": "lib" 13 | }, 14 | "engines": { 15 | "node": ">=14.16" 16 | }, 17 | "scripts": { 18 | "test": "docker compose run build && docker compose run test", 19 | "typegen": "pgtyped-rescript -c config.json", 20 | "build": "rescript", 21 | "watch": "echo 'No build step required. Use npm test instead'", 22 | "check": "tsc --noEmit" 23 | }, 24 | "dependencies": { 25 | "@rescript/core": "1.6.0", 26 | "expect": "29.6.2", 27 | "pg": "8.11.2", 28 | "pgtyped-rescript": "^2.4.0", 29 | "pgtyped-rescript-query": "^2.3.0", 30 | "rescript": "11.1.0", 31 | "rescript-embed-lang": "^0.5.1", 32 | "typescript": "4.9.4" 33 | }, 34 | "devDependencies": { 35 | "@types/pg": "8.10.2", 36 | "ts-node": "10.9.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/example/rescript.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pgtyped", 3 | "sources": { 4 | "dir": "src", 5 | "subdirs": true 6 | }, 7 | "package-specs": { 8 | "module": "commonjs", 9 | "in-source": true 10 | }, 11 | "suffix": ".js", 12 | "bs-dependencies": ["@rescript/core", "pgtyped-rescript"], 13 | "bsc-flags": ["-open RescriptCore"], 14 | "ppx-flags": ["rescript-embed-lang/ppx"], 15 | "gentypeconfig": {} 16 | } 17 | -------------------------------------------------------------------------------- /packages/example/scripts/wait-for-postgres-then: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RETRIES=${RETRIES:-15} 4 | 5 | err_file=$(mktemp) 6 | 7 | echo "Checking postgres is up..." 8 | until pg_isready > /dev/null 2> "$err_file" || [ "$RETRIES" -eq 0 ]; do 9 | echo "Waiting for postgres server, retrying $((RETRIES=RETRIES-1)) more times..." 10 | sleep 1 11 | done 12 | 13 | [ "$RETRIES" -eq 0 ] && cat "$err_file" && echo "Ensure you have postgresql available on your host machine" && exit 1 14 | rm "$err_file" 15 | 16 | exec "$@" 17 | -------------------------------------------------------------------------------- /packages/example/sql/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id SERIAL PRIMARY KEY, 3 | email TEXT NOT NULL, 4 | user_name TEXT NOT NULL, 5 | first_name TEXT, 6 | last_name TEXT, 7 | age INT, 8 | registration_date DATE NOT NULL DEFAULT CURRENT_DATE 9 | ); 10 | 11 | COMMENT ON COLUMN users.age IS 'Age (in years)'; 12 | 13 | CREATE TYPE notification_type AS ENUM ('notification', 'reminder', 'deadline'); 14 | CREATE TYPE category AS ENUM ('thriller', 'science-fiction', 'novel'); 15 | 16 | CREATE TABLE notifications ( 17 | id SERIAL PRIMARY KEY, 18 | user_id INTEGER REFERENCES users, 19 | payload jsonb NOT NULL, 20 | type notification_type NOT NULL DEFAULT 'notification', 21 | created_at DATE NOT NULL DEFAULT CURRENT_DATE 22 | ); 23 | 24 | CREATE TABLE authors ( 25 | id SERIAL PRIMARY KEY, 26 | first_name TEXT, 27 | last_name TEXT 28 | ); 29 | 30 | CREATE TABLE books ( 31 | id SERIAL PRIMARY KEY, 32 | rank INTEGER, 33 | name TEXT, 34 | author_id INTEGER REFERENCES authors, 35 | categories category[], 36 | meta jsonb[], 37 | big_int bigint 38 | ); 39 | 40 | CREATE TABLE book_comments ( 41 | id SERIAL PRIMARY KEY, 42 | user_id INTEGER REFERENCES users, 43 | book_id INTEGER REFERENCES books, 44 | body TEXT 45 | ); 46 | 47 | INSERT INTO users (email, user_name, first_name, last_name, age) 48 | VALUES ('alex.doe@example.com', 'alexd', 'Alex', 'Doe', 35), 49 | ('jane.holmes@example.com', 'jane67', 'Jane', 'Holmes', 23), 50 | ('andrewjackson@example.com', 'ajack9', 'Andrew', 'Jackson', 19); 51 | 52 | INSERT INTO notifications (user_id, payload) 53 | VALUES (1, '{ 54 | "message": "You have new frogs", 55 | "num_frogs": 2, 56 | "history": [ 57 | { 58 | "event": "NewFrog", 59 | "timestamp": "2020-05-05T17:12:25+01:00" 60 | }, 61 | { 62 | "event": "NewFrog", 63 | "timestamp": "2020-05-05T17:13:04+01:00" 64 | } 65 | ] 66 | }'); 67 | 68 | INSERT INTO authors (first_name, last_name) 69 | VALUES ('Nassim', 'Taleb'), 70 | ('Carl', 'Sagan'), 71 | ('Bertolt', 'Brecht'); 72 | 73 | INSERT INTO books (rank, name, author_id) 74 | VALUES (1, 'Black Swan', 1), 75 | (4, 'The Dragons Of Eden', 2), 76 | (2, 'Mysteries of a Barbershop', 3), 77 | (3, 'In the Jungle of Cities', 3); 78 | 79 | INSERT INTO book_comments (user_id, book_id, body) 80 | VALUES (1, 1, 'Fantastic read, recommend it!'), 81 | (1, 2, 'Did not like it, expected much more...'); 82 | 83 | CREATE TYPE "Iso31661Alpha2" AS ENUM ( 84 | 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 85 | 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 86 | 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 87 | 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 88 | 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 89 | 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 90 | 'LC', 'LI', 'LK', 'IO', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 91 | 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 92 | 'MO', 'MP', 'MQ', 'MR', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 93 | 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 94 | 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM' 95 | -- Will sometime stay hanging when we add these countries 96 | --, 'TN', 'TO', 'TR', 'TT', 'TV' 97 | ); 98 | 99 | CREATE TABLE book_country ( 100 | id SERIAL PRIMARY KEY, 101 | country "Iso31661Alpha2" NOT NULL 102 | ); 103 | 104 | INSERT INTO book_country (country) 105 | VALUES ('CZ'), ('DE'); 106 | -------------------------------------------------------------------------------- /packages/example/src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`insert query with an inline sql comment 1`] = ` 4 | Object { 5 | "body": "Just a comment", 6 | "book_id": null, 7 | "id": Any, 8 | "user_id": 1, 9 | } 10 | `; 11 | 12 | exports[`select exists query, testing #472 1`] = ` 13 | Array [ 14 | Object { 15 | "isTransactionExists": true, 16 | }, 17 | ] 18 | `; 19 | 20 | exports[`select query nullability override on return field 1`] = ` 21 | Array [ 22 | Object { 23 | "id": 1, 24 | "name": "Black Swan", 25 | }, 26 | Object { 27 | "id": 2, 28 | "name": "The Dragons Of Eden", 29 | }, 30 | Object { 31 | "id": 3, 32 | "name": "Mysteries of a Barbershop", 33 | }, 34 | Object { 35 | "id": 4, 36 | "name": "In the Jungle of Cities", 37 | }, 38 | ] 39 | `; 40 | 41 | exports[`select query with dynamic or 1`] = ` 42 | Array [ 43 | Object { 44 | "id": 1, 45 | "name": "Black Swan", 46 | }, 47 | ] 48 | `; 49 | 50 | exports[`select query with join and a parameter override 1`] = ` 51 | Array [ 52 | Object { 53 | "author_id": 2, 54 | "categories": null, 55 | "id": 2, 56 | "name": "The Dragons Of Eden", 57 | "rank": 4, 58 | }, 59 | ] 60 | `; 61 | 62 | exports[`select query with json fields and casts 1`] = `Array []`; 63 | 64 | exports[`select query with parameters 1`] = ` 65 | Array [ 66 | Object { 67 | "body": "Fantastic read, recommend it!", 68 | "book_id": 1, 69 | "id": 1, 70 | "user_id": 1, 71 | }, 72 | Object { 73 | "body": "Did not like it, expected much more...", 74 | "book_id": 2, 75 | "id": 2, 76 | "user_id": 1, 77 | }, 78 | ] 79 | `; 80 | 81 | exports[`select query with unicode characters 1`] = `Array []`; 82 | -------------------------------------------------------------------------------- /packages/example/src/__snapshots__/rescript.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`insert query with an inline sql comment 1`] = ` 4 | Object { 5 | "body": "Just a comment", 6 | "book_id": undefined, 7 | "id": Any, 8 | "user_id": 1, 9 | } 10 | `; 11 | 12 | exports[`select exists query, testing #472 1`] = ` 13 | Array [ 14 | Object { 15 | "isTransactionExists": true, 16 | }, 17 | ] 18 | `; 19 | 20 | exports[`select query nullability override on return field 1`] = ` 21 | Array [ 22 | Object { 23 | "id": 1, 24 | "name": "Black Swan", 25 | }, 26 | Object { 27 | "id": 2, 28 | "name": "The Dragons Of Eden", 29 | }, 30 | Object { 31 | "id": 3, 32 | "name": "Mysteries of a Barbershop", 33 | }, 34 | Object { 35 | "id": 4, 36 | "name": "In the Jungle of Cities", 37 | }, 38 | ] 39 | `; 40 | 41 | exports[`select query with dynamic or 1`] = ` 42 | Array [ 43 | Object { 44 | "id": 1, 45 | "name": "Black Swan", 46 | }, 47 | ] 48 | `; 49 | 50 | exports[`select query with join and a parameter override 1`] = ` 51 | Array [ 52 | Object { 53 | "author_id": 2, 54 | "big_int": undefined, 55 | "categories": undefined, 56 | "id": 2, 57 | "meta": undefined, 58 | "name": "The Dragons Of Eden", 59 | "rank": 4, 60 | }, 61 | ] 62 | `; 63 | 64 | exports[`select query with json fields and casts 1`] = `Array []`; 65 | 66 | exports[`select query with parameters 1`] = ` 67 | Array [ 68 | Object { 69 | "body": "Fantastic read, recommend it!", 70 | "book_id": 1, 71 | "id": 1, 72 | "user_id": 1, 73 | }, 74 | Object { 75 | "body": "Did not like it, expected much more...", 76 | "book_id": 2, 77 | "id": 2, 78 | "user_id": 1, 79 | }, 80 | ] 81 | `; 82 | 83 | exports[`select query with unicode characters 1`] = `Array []`; 84 | -------------------------------------------------------------------------------- /packages/example/src/books/BookService.res: -------------------------------------------------------------------------------- 1 | let findBookById = (client, ~id) => { 2 | let query = %sql.one(` 3 | /* @name FindBookById */ 4 | SELECT * FROM books WHERE id = :id; 5 | `) 6 | 7 | client->query({id: id}) 8 | } 9 | 10 | let booksByAuthor = (client, ~authorName) => { 11 | let query = %sql.many(` 12 | SELECT b.* FROM books b 13 | INNER JOIN authors a ON a.id = b.author_id 14 | WHERE a.first_name || ' ' || a.last_name = :authorName!; 15 | `) 16 | 17 | client->query({authorName: authorName}) 18 | } 19 | 20 | let queryWithParams = %sql.one(` 21 | /* 22 | @param notification -> (payload, user_id, type) 23 | */ 24 | INSERT INTO notifications (payload, user_id, type) VALUES :notification 25 | `) 26 | 27 | let queryWithParamsSingleLine = %sql.one(` 28 | /* @param notification -> (payload, user_id, type) */ 29 | INSERT INTO notifications (payload, user_id, type) VALUES :notification 30 | `) 31 | -------------------------------------------------------------------------------- /packages/example/src/books/books.sql: -------------------------------------------------------------------------------- 1 | /* @name FindBookById */ 2 | SELECT * FROM books WHERE id = :id; 3 | 4 | /* @name FindBookByCategory */ 5 | SELECT * FROM books WHERE :category = ANY(categories); 6 | 7 | /* @name FindBookNameOrRank */ 8 | SELECT id, name 9 | FROM books 10 | WHERE (name = :name OR rank = :rank); 11 | 12 | /* @name FindBookUnicode */ 13 | SELECT * FROM books WHERE name = 'שקל'; 14 | 15 | /* 16 | @name InsertBooks 17 | @param books -> ((rank!, name!, authorId!, categories)...) 18 | */ 19 | INSERT INTO books (rank, name, author_id, categories) 20 | VALUES :books RETURNING id as book_id; 21 | 22 | /* 23 | @name InsertBook 24 | */ 25 | INSERT INTO books (rank, name, author_id, categories) 26 | VALUES (:rank!, :name!, :author_id!, :categories) RETURNING id as book_id; 27 | 28 | /* 29 | @name UpdateBooksCustom 30 | */ 31 | UPDATE books 32 | SET 33 | rank = ( 34 | CASE WHEN (:rank::int IS NOT NULL) 35 | THEN :rank 36 | ELSE rank 37 | END 38 | ) 39 | WHERE id = :id!; 40 | 41 | /* 42 | @name UpdateBooks 43 | */ 44 | UPDATE books 45 | /* ignored comment */ 46 | SET 47 | name = :name, 48 | rank = :rank 49 | WHERE id = :id!; 50 | 51 | /* 52 | @name UpdateBooksRankNotNull 53 | */ 54 | UPDATE books 55 | SET 56 | rank = :rank!, 57 | name = :name 58 | WHERE id = :id!; 59 | 60 | /* @name GetBooksByAuthorName */ 61 | SELECT b.* FROM books b 62 | INNER JOIN authors a ON a.id = b.author_id 63 | WHERE a.first_name || ' ' || a.last_name = :authorName!; 64 | 65 | /* @name AggregateEmailsAndTest */ 66 | SELECT array_agg(email) as "emails!", array_agg(age) = :testAges as ageTest FROM users; 67 | 68 | /* @name GetBooks */ 69 | SELECT id, name as "name!" FROM books; 70 | 71 | /* @name CountBooks */ 72 | SELECT count(*) as book_count FROM books; 73 | 74 | /* @name GetBookCountries */ 75 | SELECT * FROM book_country; 76 | -------------------------------------------------------------------------------- /packages/example/src/comments/comments.queries.ts: -------------------------------------------------------------------------------- 1 | /** Types generated for queries found in "src/comments/comments.sql" */ 2 | import { PreparedQuery } from 'pgtyped-rescript-runtime'; 3 | 4 | /** 'GetAllComments' parameters type */ 5 | export interface IGetAllCommentsParams { 6 | id: number; 7 | } 8 | 9 | /** 'GetAllComments' return type */ 10 | export interface IGetAllCommentsResult { 11 | body: string | null; 12 | book_id: number | null; 13 | id: number; 14 | user_id: number | null; 15 | } 16 | 17 | /** 'GetAllComments' query type */ 18 | export interface IGetAllCommentsQuery { 19 | params: IGetAllCommentsParams; 20 | result: IGetAllCommentsResult; 21 | } 22 | 23 | const getAllCommentsIR: any = {"usedParamSet":{"id":true},"params":[{"name":"id","required":true,"transform":{"type":"scalar"},"locs":[{"a":39,"b":42},{"a":57,"b":59}]}],"statement":"SELECT * FROM book_comments WHERE id = :id! OR user_id = :id "}; 24 | 25 | /** 26 | * Query generated from SQL: 27 | * ``` 28 | * SELECT * FROM book_comments WHERE id = :id! OR user_id = :id 29 | * ``` 30 | */ 31 | export const getAllComments = new PreparedQuery(getAllCommentsIR); 32 | 33 | 34 | /** 'GetAllCommentsByIds' parameters type */ 35 | export interface IGetAllCommentsByIdsParams { 36 | ids: readonly (number)[]; 37 | } 38 | 39 | /** 'GetAllCommentsByIds' return type */ 40 | export interface IGetAllCommentsByIdsResult { 41 | body: string | null; 42 | book_id: number | null; 43 | id: number; 44 | user_id: number | null; 45 | } 46 | 47 | /** 'GetAllCommentsByIds' query type */ 48 | export interface IGetAllCommentsByIdsQuery { 49 | params: IGetAllCommentsByIdsParams; 50 | result: IGetAllCommentsByIdsResult; 51 | } 52 | 53 | const getAllCommentsByIdsIR: any = {"usedParamSet":{"ids":true},"params":[{"name":"ids","required":true,"transform":{"type":"array_spread"},"locs":[{"a":40,"b":43},{"a":55,"b":59}]}],"statement":"SELECT * FROM book_comments WHERE id in :ids AND id in :ids!"}; 54 | 55 | /** 56 | * Query generated from SQL: 57 | * ``` 58 | * SELECT * FROM book_comments WHERE id in :ids AND id in :ids! 59 | * ``` 60 | */ 61 | export const getAllCommentsByIds = new PreparedQuery(getAllCommentsByIdsIR); 62 | 63 | 64 | /** 'InsertComment' parameters type */ 65 | export interface IInsertCommentParams { 66 | comments: readonly ({ 67 | userId: number, 68 | commentBody: string 69 | })[]; 70 | } 71 | 72 | /** 'InsertComment' return type */ 73 | export interface IInsertCommentResult { 74 | body: string | null; 75 | book_id: number | null; 76 | id: number; 77 | user_id: number | null; 78 | } 79 | 80 | /** 'InsertComment' query type */ 81 | export interface IInsertCommentQuery { 82 | params: IInsertCommentParams; 83 | result: IInsertCommentResult; 84 | } 85 | 86 | const insertCommentIR: any = {"usedParamSet":{"comments":true},"params":[{"name":"comments","required":false,"transform":{"type":"pick_array_spread","keys":[{"name":"userId","required":true},{"name":"commentBody","required":true}]},"locs":[{"a":73,"b":81}]}],"statement":"INSERT INTO book_comments (user_id, body)\n-- NOTE: this is a note\nVALUES :comments RETURNING *"}; 87 | 88 | /** 89 | * Query generated from SQL: 90 | * ``` 91 | * INSERT INTO book_comments (user_id, body) 92 | * -- NOTE: this is a note 93 | * VALUES :comments RETURNING * 94 | * ``` 95 | */ 96 | export const insertComment = new PreparedQuery(insertCommentIR); 97 | 98 | 99 | /** 'SelectExistsTest' parameters type */ 100 | export type ISelectExistsTestParams = void; 101 | 102 | /** 'SelectExistsTest' return type */ 103 | export interface ISelectExistsTestResult { 104 | isTransactionExists: boolean | null; 105 | } 106 | 107 | /** 'SelectExistsTest' query type */ 108 | export interface ISelectExistsTestQuery { 109 | params: ISelectExistsTestParams; 110 | result: ISelectExistsTestResult; 111 | } 112 | 113 | const selectExistsTestIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT EXISTS ( SELECT 1 WHERE true ) AS \"isTransactionExists\""}; 114 | 115 | /** 116 | * Query generated from SQL: 117 | * ``` 118 | * SELECT EXISTS ( SELECT 1 WHERE true ) AS "isTransactionExists" 119 | * ``` 120 | */ 121 | export const selectExistsTest = new PreparedQuery(selectExistsTestIR); 122 | 123 | 124 | -------------------------------------------------------------------------------- /packages/example/src/comments/comments.sql: -------------------------------------------------------------------------------- 1 | /* A query to get all comments */ 2 | /* @name GetAllComments */ 3 | SELECT * FROM book_comments WHERE id = :id! OR user_id = :id; 4 | 5 | /* A query to get multiple comments */ 6 | /* 7 | @name GetAllCommentsByIds 8 | @param ids -> (...) 9 | */ 10 | SELECT * FROM book_comments WHERE id in :ids AND id in :ids!; 11 | 12 | /* 13 | @name InsertComment 14 | @param comments -> ((userId!, commentBody!)...) 15 | */ 16 | INSERT INTO book_comments (user_id, body) 17 | -- NOTE: this is a note 18 | VALUES :comments RETURNING *; 19 | 20 | /* 21 | @name SelectExistsTest 22 | */ 23 | SELECT EXISTS ( SELECT 1 WHERE true ) AS "isTransactionExists"; 24 | -------------------------------------------------------------------------------- /packages/example/src/customTypes.ts: -------------------------------------------------------------------------------- 1 | export enum Category { 2 | Novel = 'novel', 3 | ScienceFiction = 'science-fiction', 4 | Thriller = 'thriller', 5 | } 6 | -------------------------------------------------------------------------------- /packages/example/src/notifications/notifications.queries.ts: -------------------------------------------------------------------------------- 1 | /** Types generated for queries found in "src/notifications/notifications.sql" */ 2 | import { PreparedQuery } from 'pgtyped-rescript-runtime'; 3 | 4 | export type notification_type = 'deadline' | 'notification' | 'reminder'; 5 | 6 | export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; 7 | 8 | /** 'SendNotifications' parameters type */ 9 | export interface ISendNotificationsParams { 10 | notifications: readonly ({ 11 | user_id: number, 12 | payload: Json, 13 | type: notification_type 14 | })[]; 15 | } 16 | 17 | /** 'SendNotifications' return type */ 18 | export interface ISendNotificationsResult { 19 | notification_id: number; 20 | } 21 | 22 | /** 'SendNotifications' query type */ 23 | export interface ISendNotificationsQuery { 24 | params: ISendNotificationsParams; 25 | result: ISendNotificationsResult; 26 | } 27 | 28 | const sendNotificationsIR: any = {"usedParamSet":{"notifications":true},"params":[{"name":"notifications","required":false,"transform":{"type":"pick_array_spread","keys":[{"name":"user_id","required":true},{"name":"payload","required":true},{"name":"type","required":true}]},"locs":[{"a":58,"b":71}]}],"statement":"INSERT INTO notifications (user_id, payload, type)\nVALUES :notifications RETURNING id as notification_id"}; 29 | 30 | /** 31 | * Query generated from SQL: 32 | * ``` 33 | * INSERT INTO notifications (user_id, payload, type) 34 | * VALUES :notifications RETURNING id as notification_id 35 | * ``` 36 | */ 37 | export const sendNotifications = new PreparedQuery(sendNotificationsIR); 38 | 39 | 40 | /** 'GetNotifications' parameters type */ 41 | export interface IGetNotificationsParams { 42 | date: Date | string; 43 | userId?: number | null | void; 44 | } 45 | 46 | /** 'GetNotifications' return type */ 47 | export interface IGetNotificationsResult { 48 | created_at: string; 49 | id: number; 50 | payload: Json; 51 | type: notification_type; 52 | user_id: number | null; 53 | } 54 | 55 | /** 'GetNotifications' query type */ 56 | export interface IGetNotificationsQuery { 57 | params: IGetNotificationsParams; 58 | result: IGetNotificationsResult; 59 | } 60 | 61 | const getNotificationsIR: any = {"usedParamSet":{"userId":true,"date":true},"params":[{"name":"userId","required":false,"transform":{"type":"scalar"},"locs":[{"a":47,"b":53}]},{"name":"date","required":true,"transform":{"type":"scalar"},"locs":[{"a":73,"b":78}]}],"statement":"SELECT *\n FROM notifications\n WHERE user_id = :userId\n AND created_at > :date!"}; 62 | 63 | /** 64 | * Query generated from SQL: 65 | * ``` 66 | * SELECT * 67 | * FROM notifications 68 | * WHERE user_id = :userId 69 | * AND created_at > :date! 70 | * ``` 71 | */ 72 | export const getNotifications = new PreparedQuery(getNotificationsIR); 73 | 74 | 75 | /** 'ThresholdFrogs' parameters type */ 76 | export interface IThresholdFrogsParams { 77 | numFrogs: number; 78 | } 79 | 80 | /** 'ThresholdFrogs' return type */ 81 | export interface IThresholdFrogsResult { 82 | payload: Json; 83 | type: notification_type; 84 | user_name: string; 85 | } 86 | 87 | /** 'ThresholdFrogs' query type */ 88 | export interface IThresholdFrogsQuery { 89 | params: IThresholdFrogsParams; 90 | result: IThresholdFrogsResult; 91 | } 92 | 93 | const thresholdFrogsIR: any = {"usedParamSet":{"numFrogs":true},"params":[{"name":"numFrogs","required":true,"transform":{"type":"scalar"},"locs":[{"a":143,"b":152}]}],"statement":"SELECT u.user_name, n.payload, n.type\nFROM notifications n\nINNER JOIN users u on n.user_id = u.id\nWHERE CAST (n.payload->'num_frogs' AS int) > :numFrogs!"}; 94 | 95 | /** 96 | * Query generated from SQL: 97 | * ``` 98 | * SELECT u.user_name, n.payload, n.type 99 | * FROM notifications n 100 | * INNER JOIN users u on n.user_id = u.id 101 | * WHERE CAST (n.payload->'num_frogs' AS int) > :numFrogs! 102 | * ``` 103 | */ 104 | export const thresholdFrogs = new PreparedQuery(thresholdFrogsIR); 105 | 106 | 107 | -------------------------------------------------------------------------------- /packages/example/src/notifications/notifications.sql: -------------------------------------------------------------------------------- 1 | /* 2 | @name SendNotifications 3 | @param notifications -> ((user_id!, payload!, type!)...) 4 | */ 5 | INSERT INTO notifications (user_id, payload, type) 6 | VALUES :notifications RETURNING id as notification_id; 7 | 8 | /* @name GetNotifications */ 9 | SELECT * 10 | FROM notifications 11 | WHERE user_id = :userId 12 | AND created_at > :date!; 13 | 14 | 15 | /* 16 | @name ThresholdFrogs 17 | */ 18 | SELECT u.user_name, n.payload, n.type 19 | FROM notifications n 20 | INNER JOIN users u on n.user_id = u.id 21 | WHERE CAST (n.payload->'num_frogs' AS int) > :numFrogs!; 22 | -------------------------------------------------------------------------------- /packages/example/src/notifications/notifications.ts: -------------------------------------------------------------------------------- 1 | import { sql } from 'pgtyped-rescript-runtime'; 2 | import { 3 | IInsertNotificationQuery, 4 | IInsertNotificationsQuery, 5 | IGetAllNotificationsQuery, 6 | } from './notifications.types.js'; 7 | 8 | // Table order is (user_id, payload, type) 9 | export const insertNotifications = sql` 10 | INSERT INTO notifications (payload, user_id, type) 11 | values $$params(payload!, user_id!, type!) 12 | `; 13 | 14 | export const insertNotification = sql` 15 | INSERT INTO notifications (payload, user_id, type) 16 | values $notification(payload!, user_id!, type!) 17 | `; 18 | 19 | export const getAllNotifications = sql` 20 | SELECT * FROM notifications 21 | `; 22 | -------------------------------------------------------------------------------- /packages/example/src/notifications/notifications.types.ts: -------------------------------------------------------------------------------- 1 | /** Types generated for queries found in "src/notifications/notifications.ts" */ 2 | export type notification_type = 'deadline' | 'notification' | 'reminder'; 3 | 4 | export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; 5 | 6 | /** 'InsertNotifications' parameters type */ 7 | export interface IInsertNotificationsParams { 8 | params: readonly ({ 9 | payload: Json, 10 | user_id: number, 11 | type: notification_type 12 | })[]; 13 | } 14 | 15 | /** 'InsertNotifications' return type */ 16 | export type IInsertNotificationsResult = void; 17 | 18 | /** 'InsertNotifications' query type */ 19 | export interface IInsertNotificationsQuery { 20 | params: IInsertNotificationsParams; 21 | result: IInsertNotificationsResult; 22 | } 23 | 24 | /** 'InsertNotification' parameters type */ 25 | export interface IInsertNotificationParams { 26 | notification: { 27 | payload: Json, 28 | user_id: number, 29 | type: notification_type 30 | }; 31 | } 32 | 33 | /** 'InsertNotification' return type */ 34 | export type IInsertNotificationResult = void; 35 | 36 | /** 'InsertNotification' query type */ 37 | export interface IInsertNotificationQuery { 38 | params: IInsertNotificationParams; 39 | result: IInsertNotificationResult; 40 | } 41 | 42 | /** 'GetAllNotifications' parameters type */ 43 | export type IGetAllNotificationsParams = void; 44 | 45 | /** 'GetAllNotifications' return type */ 46 | export interface IGetAllNotificationsResult { 47 | created_at: string; 48 | id: number; 49 | payload: Json; 50 | type: notification_type; 51 | user_id: number | null; 52 | } 53 | 54 | /** 'GetAllNotifications' query type */ 55 | export interface IGetAllNotificationsQuery { 56 | params: IGetAllNotificationsParams; 57 | result: IGetAllNotificationsResult; 58 | } 59 | 60 | -------------------------------------------------------------------------------- /packages/example/src/users/sample.ts: -------------------------------------------------------------------------------- 1 | import { sql } from 'pgtyped-rescript-runtime'; 2 | import { IGetUsersWithCommentsQuery } from './sample.types.js'; 3 | import { Client } from 'pg'; 4 | 5 | export async function getUsersWithComment( 6 | minCommentCount: number, 7 | client: Client, 8 | ) { 9 | const getUsersWithComments = sql` 10 | SELECT u.* FROM users u 11 | INNER JOIN book_comments bc ON u.id = bc.user_id 12 | GROUP BY u.id 13 | HAVING count(bc.id) > $minCommentCount!::int`; 14 | const result = await getUsersWithComments.run({ minCommentCount }, client); 15 | return result[0]; 16 | } 17 | 18 | const selectExistsQuery = sql`SELECT EXISTS ( SELECT 1 WHERE true ) AS "isTransactionExists";`; 19 | -------------------------------------------------------------------------------- /packages/example/src/users/sample.types.ts: -------------------------------------------------------------------------------- 1 | /** Types generated for queries found in "src/users/sample.ts" */ 2 | 3 | /** 'GetUsersWithComments' parameters type */ 4 | export interface IGetUsersWithCommentsParams { 5 | minCommentCount: number; 6 | } 7 | 8 | /** 'GetUsersWithComments' return type */ 9 | export interface IGetUsersWithCommentsResult { 10 | /** Age (in years) */ 11 | age: number | null; 12 | email: string; 13 | first_name: string | null; 14 | id: number; 15 | last_name: string | null; 16 | registration_date: string; 17 | user_name: string; 18 | } 19 | 20 | /** 'GetUsersWithComments' query type */ 21 | export interface IGetUsersWithCommentsQuery { 22 | params: IGetUsersWithCommentsParams; 23 | result: IGetUsersWithCommentsResult; 24 | } 25 | 26 | /** 'SelectExistsQuery' parameters type */ 27 | export type ISelectExistsQueryParams = void; 28 | 29 | /** 'SelectExistsQuery' return type */ 30 | export interface ISelectExistsQueryResult { 31 | isTransactionExists: boolean | null; 32 | } 33 | 34 | /** 'SelectExistsQuery' query type */ 35 | export interface ISelectExistsQueryQuery { 36 | params: ISelectExistsQueryParams; 37 | result: ISelectExistsQueryResult; 38 | } 39 | 40 | -------------------------------------------------------------------------------- /packages/example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib/", 5 | "rootDir": "./src/", 6 | "module": "commonjs", 7 | }, 8 | "exclude": [ 9 | "lib" 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /packages/parser/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib -------------------------------------------------------------------------------- /packages/parser/README.md: -------------------------------------------------------------------------------- 1 | ## @pgtyped/runtime 2 | 3 | This package provides SQL and TS query parsing for pgTyped. 4 | 5 | Refer to [README](https://github.com/adelsz/pgtyped) for details. 6 | -------------------------------------------------------------------------------- /packages/parser/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | snapshotFormat: { 5 | escapeString: true, 6 | printBasicPrototype: true, 7 | }, 8 | roots: ['src'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | useESM: true, 17 | }, 18 | ], 19 | }, 20 | preset: 'ts-jest/presets/default-esm', 21 | testRegex: '\\.test\\.tsx?$', 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /packages/parser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pgtyped/parser", 3 | "version": "2.1.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "import": "./lib/index.js", 8 | "types": "./lib/index.d.ts" 9 | } 10 | }, 11 | "main": "lib/index.js", 12 | "types": "lib/index.d.ts", 13 | "files": [ 14 | "lib" 15 | ], 16 | "engines": { 17 | "node": ">=14.16" 18 | }, 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/adelsz/pgtyped.git" 23 | }, 24 | "homepage": "https://github.com/adelsz/pgtyped", 25 | "publishConfig": { 26 | "access": "public" 27 | }, 28 | "scripts": { 29 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", 30 | "build": "tsc", 31 | "check": "tsc --noEmit", 32 | "watch": "tsc --declaration --watch --preserveWatchOutput", 33 | "parsegen-sql": "antlr4ts -visitor -Xexact-output-dir -o src/loader/sql/parser src/loader/sql/grammar/*.g4", 34 | "parsegen-ts": "antlr4ts -visitor -Xexact-output-dir -o src/loader/typescript/parser src/loader/typescript/grammar/*.g4" 35 | }, 36 | "dependencies": { 37 | "antlr4ts": "0.5.0-alpha.4", 38 | "chalk": "^4.1.0", 39 | "debug": "^4.1.1" 40 | }, 41 | "devDependencies": { 42 | "@types/chalk": "^2.2.0", 43 | "@types/debug": "^4.1.4", 44 | "antlr4ts-cli": "0.5.0-alpha.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/parser/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as parseTSQuery, 3 | Query as TSQueryAST, 4 | } from './loader/typescript/query.js'; 5 | 6 | export { Param, ParamKey, ParamType } from './loader/typescript/query.js'; 7 | 8 | export { 9 | default as parseSQLFile, 10 | SQLQueryAST, 11 | ParseEvent, 12 | SQLQueryIR, 13 | prettyPrintEvents, 14 | queryASTToIR, 15 | assert, 16 | TransformType, 17 | } from './loader/sql/index.js'; 18 | -------------------------------------------------------------------------------- /packages/parser/src/loader/sql/grammar/SQLLexer.g4: -------------------------------------------------------------------------------- 1 | /* 2 | -- @name GetAllUsers 3 | -- @param userNames -> (...) 4 | -- @param user -> (name,age) 5 | -- @param users -> ((name,age)...) 6 | select * from $userNames; 7 | select * from $books $filterById; 8 | */ 9 | 10 | lexer grammar SQLLexer; 11 | 12 | tokens { ID } 13 | 14 | fragment QUOT: '\''; 15 | fragment ID: [a-zA-Z_][a-zA-Z_0-9]*; 16 | 17 | LINE_COMMENT: '--' ~[\r\n]* '\r'? '\n'; 18 | OPEN_COMMENT: '/*' -> mode(COMMENT); 19 | SID: ID -> type(ID); 20 | S_REQUIRED_MARK: '!'; 21 | WORD: [a-zA-Z_0-9]+; 22 | SPECIAL: [\-+*/<>=~@#%^&|`?(){},.[\]"]+ -> type(WORD); 23 | DOLLAR: '$' -> type(WORD); 24 | EOF_STATEMENT: ';'; 25 | WSL : [ \t\r\n]+ -> skip; 26 | // parse strings and recognize escaped quotes 27 | STRING: QUOT (QUOT | .*? ~([\\]) QUOT); 28 | DOLLAR_STRING: DOLLAR WORD? DOLLAR .* DOLLAR WORD? DOLLAR; 29 | PARAM_MARK: ':'; 30 | CAST: '::' -> type(WORD); 31 | 32 | mode COMMENT; 33 | CID: ID -> type(ID); 34 | WS : [ \t\r\n]+ -> skip; 35 | TRANSFORM_ARROW: '->'; 36 | SPREAD: '...'; 37 | NAME_TAG : '@name'; 38 | TYPE_TAG : '@param'; 39 | OB: '('; 40 | CB: ')'; 41 | COMMA: ','; 42 | C_REQUIRED_MARK: '!'; 43 | ANY: .+?; 44 | CLOSE_COMMENT: '*/' -> mode(DEFAULT_MODE); 45 | -------------------------------------------------------------------------------- /packages/parser/src/loader/sql/grammar/SQLParser.g4: -------------------------------------------------------------------------------- 1 | parser grammar SQLParser; 2 | 3 | options { tokenVocab = SQLLexer; } 4 | 5 | input 6 | : (ignoredComment* query)+ EOF 7 | ; 8 | 9 | query 10 | : queryDef statement 11 | ; 12 | 13 | queryDef: OPEN_COMMENT nameTag paramTag* CLOSE_COMMENT; 14 | 15 | ignoredComment 16 | : OPEN_COMMENT (~CLOSE_COMMENT)* CLOSE_COMMENT; 17 | 18 | statement 19 | : statementBody EOF_STATEMENT; 20 | 21 | statementBody 22 | : (LINE_COMMENT | ignoredComment | param | word | range)*; 23 | 24 | word: WORD | ID | STRING | S_REQUIRED_MARK | DOLLAR_STRING; 25 | 26 | // required to avoid errors when matching strings like "[1:2]" as params 27 | range: PARAM_MARK word; 28 | 29 | param: PARAM_MARK paramId; 30 | 31 | paramId: ID S_REQUIRED_MARK?; 32 | 33 | nameTag: NAME_TAG queryName; 34 | 35 | paramTag: TYPE_TAG paramName paramTransform; 36 | 37 | paramTransform: TRANSFORM_ARROW transformRule; 38 | 39 | transformRule 40 | : spreadTransform 41 | | pickTransform 42 | | spreadPickTransform; 43 | 44 | spreadTransform: OB SPREAD CB; 45 | 46 | pickTransform: OB key (COMMA key)* COMMA? CB; 47 | 48 | spreadPickTransform: OB pickTransform SPREAD CB; 49 | 50 | key: ID C_REQUIRED_MARK?; 51 | 52 | queryName: ID; 53 | paramName: ID; 54 | -------------------------------------------------------------------------------- /packages/parser/src/loader/sql/index.test.ts: -------------------------------------------------------------------------------- 1 | import parse from './index.js'; 2 | 3 | test('Named query', () => { 4 | const text = ` 5 | /* @name GetAllUsers */ 6 | SELECT * FROM users;`; 7 | const parseTree = parse(text); 8 | expect(parseTree).toMatchSnapshot(); 9 | }); 10 | 11 | test('Named query selects some fields', () => { 12 | const text = ` 13 | /* @name GetAllUsers */ 14 | SELECT id, name FROM users;`; 15 | const parseTree = parse(text); 16 | expect(parseTree).toMatchSnapshot(); 17 | }); 18 | 19 | test('Named query with an inferred param', () => { 20 | const text = ` 21 | /* @name GetUserById */ 22 | SELECT * FROM users WHERE userId = :userId;`; 23 | const parseTree = parse(text); 24 | expect(parseTree).toMatchSnapshot(); 25 | }); 26 | 27 | test('Named query with two inferred params', () => { 28 | const text = ` 29 | /* @name GetUserById */ 30 | SELECT * FROM users WHERE userId = :userId or parentId = :userId;`; 31 | const parseTree = parse(text); 32 | expect(parseTree).toMatchSnapshot(); 33 | }); 34 | 35 | test('Named query with a valid param', () => { 36 | const text = ` 37 | /* 38 | @name CreateCustomer 39 | @param customers -> (customerName, contactName, address) 40 | */ 41 | INSERT INTO customers (customer_name, contact_name, address) 42 | VALUES :customers;`; 43 | const parseTree = parse(text); 44 | expect(parseTree).toMatchSnapshot(); 45 | }); 46 | 47 | test('Named query with pick param used twice', () => { 48 | const text = ` 49 | /* 50 | @name CreateCustomer 51 | @param customers -> (customerName, contactName, address) 52 | */ 53 | INSERT INTO customers (customer_name, contact_name, address) 54 | VALUES :customers, :customers;`; 55 | const parseTree = parse(text); 56 | expect(parseTree).toMatchSnapshot(); 57 | }); 58 | 59 | test('Unused parameters produce warnings', () => { 60 | const text = ` 61 | /* 62 | @name GetAllUsers 63 | @param userNames -> (...) 64 | @param users -> ((name,time)...) 65 | */ 66 | SELECT * FROM users;`; 67 | const parseTree = parse(text); 68 | expect(parseTree).toMatchSnapshot(); 69 | }); 70 | 71 | test('Another test', () => { 72 | const text = ` 73 | /* @name GetBooksByAuthorName */ 74 | SELECT b.* FROM books b 75 | INNER JOIN authors a ON a.id = b.author_id 76 | WHERE a.first_name || ' ' || a.last_name = :authorName;`; 77 | const parseTree = parse(text); 78 | expect(parseTree).toMatchSnapshot(); 79 | }); 80 | 81 | test('Double and single quotes are supported', () => { 82 | const text = ` 83 | /* @name GetAllUsers */ 84 | SELECT u."rank" FROM users u where name = 'some-name';`; 85 | const parseTree = parse(text); 86 | expect(parseTree).toMatchSnapshot(); 87 | }); 88 | 89 | test('Postgres cast operator is correctly parsed', () => { 90 | const text = ` 91 | /* @name GetAllUsers */ 92 | SELECT u."rank" FROM users u where name = :name::text;`; 93 | const parseTree = parse(text); 94 | expect(parseTree).toMatchSnapshot(); 95 | }); 96 | 97 | test('Ignore multi-line comments in queries', () => { 98 | const text = ` 99 | /* @name UpdateBooks */ 100 | UPDATE books 101 | /* ignored comment foo: bar's */ 102 | SET name = :name, rank = :rank WHERE id = :id; 103 | `; 104 | const parseTree = parse(text); 105 | expect(parseTree).toMatchSnapshot(); 106 | }); 107 | 108 | test('Ignore params in inline single-line comments in queries', () => { 109 | const text = ` 110 | /* @name UpdateBooks */ 111 | UPDATE books 112 | -- ignored comment foo: bar's 113 | SET name = :name, rank = :rank WHERE id = :id; 114 | `; 115 | const parseTree = parse(text); 116 | expect(parseTree).toMatchSnapshot(); 117 | }); 118 | 119 | test('Include inline single-line comments in statement body', () => { 120 | const text = ` 121 | /* @name UpdateBooks */ 122 | -- Inline comment 1 123 | UPDATE books 124 | -- Inline comment 2: 125 | SET name = :name, rank = :rank WHERE id = :id 126 | -- Inline comment 3 127 | ; 128 | `; 129 | const parseTree = parse(text); 130 | expect(parseTree).toMatchSnapshot(); 131 | }); 132 | 133 | test('Comment starts in strings are ignored', () => { 134 | const text = ` 135 | /* @name UpdateBooks */ 136 | UPDATE books 137 | SET name = '-- /*', rank = :rank WHERE id = :id 138 | ; 139 | `; 140 | const parseTree = parse(text); 141 | expect(parseTree).toMatchSnapshot(); 142 | }); 143 | 144 | test('Dollar quoted strings are supported', () => { 145 | const text = ` 146 | /* @name CreateUpdatedAtFunction */ 147 | CREATE FUNCTION UpdatedAt() 148 | RETURNS TRIGGER AS $$ 149 | BEGIN 150 | NEW.updatedAt = NOW(); 151 | RETURN NEW; 152 | END; 153 | $$ LANGUAGE plpgsql;`; 154 | const parseTree = parse(text); 155 | expect(parseTree).toMatchSnapshot(); 156 | }); 157 | 158 | test('Query with a PG range', () => { 159 | const text = ` 160 | /* @name TestRange */ 161 | select (ARRAY[1,2,3,4])[2:3] as arr;`; 162 | const parseTree = parse(text); 163 | expect(parseTree).toMatchSnapshot(); 164 | }); 165 | -------------------------------------------------------------------------------- /packages/parser/src/loader/sql/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk, { ChalkFunction } from 'chalk'; 2 | import { ANTLRErrorListener } from 'antlr4ts'; 3 | import { RecognitionException } from 'antlr4ts/RecognitionException.js'; 4 | 5 | interface CodeInterval { 6 | a: number; 7 | b: number; 8 | line: number; 9 | col: number; 10 | } 11 | 12 | export enum ParseWarningType { 13 | ParamNeverUsed, 14 | } 15 | 16 | enum ParseErrorType { 17 | ParseError, 18 | } 19 | 20 | export enum ParseEventType { 21 | Info, 22 | Warning, 23 | Error, 24 | } 25 | 26 | export type ParseEvent = 27 | | { 28 | type: ParseEventType.Warning; 29 | message: { 30 | text: string; 31 | type: ParseWarningType; 32 | }; 33 | location?: CodeInterval; 34 | } 35 | | { 36 | type: ParseEventType.Error; 37 | critical: true; 38 | message: { 39 | text: string; 40 | type: ParseErrorType; 41 | }; 42 | location?: CodeInterval; 43 | }; 44 | 45 | function styleIntervals( 46 | str: string, 47 | intervals: { a: number; b: number; style: ChalkFunction }[], 48 | ) { 49 | if (intervals.length === 0) { 50 | return str; 51 | } 52 | intervals.sort((x, y) => x.a - y.a); 53 | let offset = 0; 54 | let colored = ''; 55 | for (const interval of intervals) { 56 | const a = str.slice(0, interval.a + offset); 57 | const b = str.slice(interval.a + offset, interval.b + offset + 1); 58 | const c = str.slice(interval.b + offset + 1, str.length); 59 | colored = a + interval.style(b) + c; 60 | offset += colored.length - str.length; 61 | str = colored; 62 | } 63 | return colored; 64 | } 65 | 66 | export function prettyPrintEvents(text: string, parseEvents: ParseEvent[]) { 67 | let msg = chalk.underline.magenta('Parsed file:\n'); 68 | const errors = parseEvents.filter((e) => e.type === ParseEventType.Error); 69 | const warnings = parseEvents.filter((e) => e.type === ParseEventType.Warning); 70 | const lineStyle = {} as any; 71 | const locsToColor = parseEvents 72 | .filter((w) => w.location) 73 | .map((w) => { 74 | const style = 75 | w.type === ParseEventType.Error 76 | ? chalk.underline.red 77 | : chalk.underline.yellow; 78 | if (w.location?.line) { 79 | lineStyle[w.location.line] = style; 80 | } 81 | return { 82 | ...w.location, 83 | style, 84 | }; 85 | }); 86 | const styledText = styleIntervals(text, locsToColor as any); 87 | let i = 1; 88 | const numberedText = styledText.replace(/^/gm, () => { 89 | const prefix = lineStyle[i] ? lineStyle[i]('>') : '|'; 90 | return `${i++} ${prefix} `; 91 | }); 92 | msg += numberedText; 93 | if (errors.length > 0) { 94 | msg += chalk.underline.red('\nErrors:\n'); 95 | msg += errors 96 | .map( 97 | (w) => `- (${w.location?.line}:${w.location?.col}) ${w.message.text}`, 98 | ) 99 | .join('\n'); 100 | } 101 | if (warnings.length > 0) { 102 | msg += chalk.underline.yellow('\nWarnings:\n'); 103 | msg += warnings 104 | .map( 105 | (w) => `- (${w.location?.line}:${w.location?.col}) ${w.message.text}`, 106 | ) 107 | .join('\n'); 108 | } 109 | // tslint:disable-next-line:no-console 110 | console.log(msg); 111 | } 112 | 113 | export class Logger implements ANTLRErrorListener { 114 | public parseEvents: ParseEvent[] = []; 115 | 116 | logEvent(event: ParseEvent) { 117 | this.parseEvents.push(event); 118 | } 119 | 120 | syntaxError( 121 | _recognizer: any, 122 | symbol: any, 123 | line: number, 124 | col: number, 125 | msg: string, 126 | _e: RecognitionException | undefined, 127 | ) { 128 | this.logEvent({ 129 | type: ParseEventType.Error, 130 | critical: true, 131 | message: { 132 | type: ParseErrorType.ParseError, 133 | text: `Parse error: ${msg}`, 134 | }, 135 | location: { 136 | a: symbol?.startIndex, 137 | b: symbol?.stopIndex, 138 | line, 139 | col, 140 | }, 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /packages/parser/src/loader/sql/parser/SQLParserVisitor.ts: -------------------------------------------------------------------------------- 1 | // Generated from src/loader/sql/grammar/SQLParser.g4 by ANTLR 4.9.0-SNAPSHOT 2 | 3 | 4 | import { ParseTreeVisitor } from "antlr4ts/tree/ParseTreeVisitor.js"; 5 | 6 | import {InputContext, RangeContext} from "./SQLParser.js"; 7 | import { QueryContext } from "./SQLParser.js"; 8 | import { QueryDefContext } from "./SQLParser.js"; 9 | import { IgnoredCommentContext } from "./SQLParser.js"; 10 | import { StatementContext } from "./SQLParser.js"; 11 | import { StatementBodyContext } from "./SQLParser.js"; 12 | import { WordContext } from "./SQLParser.js"; 13 | import { ParamContext } from "./SQLParser.js"; 14 | import { ParamIdContext } from "./SQLParser.js"; 15 | import { NameTagContext } from "./SQLParser.js"; 16 | import { ParamTagContext } from "./SQLParser.js"; 17 | import { ParamTransformContext } from "./SQLParser.js"; 18 | import { TransformRuleContext } from "./SQLParser.js"; 19 | import { SpreadTransformContext } from "./SQLParser.js"; 20 | import { PickTransformContext } from "./SQLParser.js"; 21 | import { SpreadPickTransformContext } from "./SQLParser.js"; 22 | import { KeyContext } from "./SQLParser.js"; 23 | import { QueryNameContext } from "./SQLParser.js"; 24 | import { ParamNameContext } from "./SQLParser.js"; 25 | 26 | 27 | /** 28 | * This interface defines a complete generic visitor for a parse tree produced 29 | * by `SQLParser`. 30 | * 31 | * @param The return type of the visit operation. Use `void` for 32 | * operations with no return type. 33 | */ 34 | export interface SQLParserVisitor extends ParseTreeVisitor { 35 | /** 36 | * Visit a parse tree produced by `SQLParser.input`. 37 | * @param ctx the parse tree 38 | * @return the visitor result 39 | */ 40 | visitInput?: (ctx: InputContext) => Result; 41 | 42 | /** 43 | * Visit a parse tree produced by `SQLParser.query`. 44 | * @param ctx the parse tree 45 | * @return the visitor result 46 | */ 47 | visitQuery?: (ctx: QueryContext) => Result; 48 | 49 | /** 50 | * Visit a parse tree produced by `SQLParser.queryDef`. 51 | * @param ctx the parse tree 52 | * @return the visitor result 53 | */ 54 | visitQueryDef?: (ctx: QueryDefContext) => Result; 55 | 56 | /** 57 | * Visit a parse tree produced by `SQLParser.ignoredComment`. 58 | * @param ctx the parse tree 59 | * @return the visitor result 60 | */ 61 | visitIgnoredComment?: (ctx: IgnoredCommentContext) => Result; 62 | 63 | /** 64 | * Visit a parse tree produced by `SQLParser.statement`. 65 | * @param ctx the parse tree 66 | * @return the visitor result 67 | */ 68 | visitStatement?: (ctx: StatementContext) => Result; 69 | 70 | /** 71 | * Visit a parse tree produced by `SQLParser.statementBody`. 72 | * @param ctx the parse tree 73 | * @return the visitor result 74 | */ 75 | visitStatementBody?: (ctx: StatementBodyContext) => Result; 76 | 77 | /** 78 | * Visit a parse tree produced by `SQLParser.word`. 79 | * @param ctx the parse tree 80 | * @return the visitor result 81 | */ 82 | visitWord?: (ctx: WordContext) => Result; 83 | 84 | /** 85 | * Visit a parse tree produced by `SQLParser.range`. 86 | * @param ctx the parse tree 87 | * @return the visitor result 88 | */ 89 | visitRange?: (ctx: RangeContext) => Result; 90 | 91 | /** 92 | * Visit a parse tree produced by `SQLParser.param`. 93 | * @param ctx the parse tree 94 | * @return the visitor result 95 | */ 96 | visitParam?: (ctx: ParamContext) => Result; 97 | 98 | /** 99 | * Visit a parse tree produced by `SQLParser.paramId`. 100 | * @param ctx the parse tree 101 | * @return the visitor result 102 | */ 103 | visitParamId?: (ctx: ParamIdContext) => Result; 104 | 105 | /** 106 | * Visit a parse tree produced by `SQLParser.nameTag`. 107 | * @param ctx the parse tree 108 | * @return the visitor result 109 | */ 110 | visitNameTag?: (ctx: NameTagContext) => Result; 111 | 112 | /** 113 | * Visit a parse tree produced by `SQLParser.paramTag`. 114 | * @param ctx the parse tree 115 | * @return the visitor result 116 | */ 117 | visitParamTag?: (ctx: ParamTagContext) => Result; 118 | 119 | /** 120 | * Visit a parse tree produced by `SQLParser.paramTransform`. 121 | * @param ctx the parse tree 122 | * @return the visitor result 123 | */ 124 | visitParamTransform?: (ctx: ParamTransformContext) => Result; 125 | 126 | /** 127 | * Visit a parse tree produced by `SQLParser.transformRule`. 128 | * @param ctx the parse tree 129 | * @return the visitor result 130 | */ 131 | visitTransformRule?: (ctx: TransformRuleContext) => Result; 132 | 133 | /** 134 | * Visit a parse tree produced by `SQLParser.spreadTransform`. 135 | * @param ctx the parse tree 136 | * @return the visitor result 137 | */ 138 | visitSpreadTransform?: (ctx: SpreadTransformContext) => Result; 139 | 140 | /** 141 | * Visit a parse tree produced by `SQLParser.pickTransform`. 142 | * @param ctx the parse tree 143 | * @return the visitor result 144 | */ 145 | visitPickTransform?: (ctx: PickTransformContext) => Result; 146 | 147 | /** 148 | * Visit a parse tree produced by `SQLParser.spreadPickTransform`. 149 | * @param ctx the parse tree 150 | * @return the visitor result 151 | */ 152 | visitSpreadPickTransform?: (ctx: SpreadPickTransformContext) => Result; 153 | 154 | /** 155 | * Visit a parse tree produced by `SQLParser.key`. 156 | * @param ctx the parse tree 157 | * @return the visitor result 158 | */ 159 | visitKey?: (ctx: KeyContext) => Result; 160 | 161 | /** 162 | * Visit a parse tree produced by `SQLParser.queryName`. 163 | * @param ctx the parse tree 164 | * @return the visitor result 165 | */ 166 | visitQueryName?: (ctx: QueryNameContext) => Result; 167 | 168 | /** 169 | * Visit a parse tree produced by `SQLParser.paramName`. 170 | * @param ctx the parse tree 171 | * @return the visitor result 172 | */ 173 | visitParamName?: (ctx: ParamNameContext) => Result; 174 | } 175 | 176 | -------------------------------------------------------------------------------- /packages/parser/src/loader/typescript/__snapshots__/query.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`array param 1`] = ` 4 | Object { 5 | "events": Array [], 6 | "query": Object { 7 | "name": "query", 8 | "params": Array [ 9 | Object { 10 | "location": Object { 11 | "a": 32, 12 | "b": 36, 13 | "col": 32, 14 | "line": 1, 15 | }, 16 | "name": "ids", 17 | "required": false, 18 | "selection": Object { 19 | "type": "scalar_array", 20 | }, 21 | }, 22 | ], 23 | "text": "select * from users where id in $$ids", 24 | }, 25 | } 26 | `; 27 | 28 | exports[`array spread param 1`] = ` 29 | Object { 30 | "events": Array [], 31 | "query": Object { 32 | "name": "query", 33 | "params": Array [ 34 | Object { 35 | "location": Object { 36 | "a": 70, 37 | "b": 116, 38 | "col": 9, 39 | "line": 2, 40 | }, 41 | "name": "customers", 42 | "selection": Object { 43 | "keys": Array [ 44 | Object { 45 | "name": "customerName", 46 | "required": false, 47 | }, 48 | Object { 49 | "name": "contactName", 50 | "required": false, 51 | }, 52 | Object { 53 | "name": "address", 54 | "required": false, 55 | }, 56 | ], 57 | "type": "object_array", 58 | }, 59 | }, 60 | ], 61 | "text": "INSERT INTO customers (customer_name, contact_name, address) 62 | VALUES $$customers(customerName, contactName, address)", 63 | }, 64 | } 65 | `; 66 | 67 | exports[`pick param 1`] = ` 68 | Object { 69 | "events": Array [], 70 | "query": Object { 71 | "name": "query", 72 | "params": Array [ 73 | Object { 74 | "location": Object { 75 | "a": 32, 76 | "b": 61, 77 | "col": 32, 78 | "line": 1, 79 | }, 80 | "name": "activeUsers", 81 | "selection": Object { 82 | "keys": Array [ 83 | Object { 84 | "name": "userOne", 85 | "required": false, 86 | }, 87 | Object { 88 | "name": "userTwo", 89 | "required": false, 90 | }, 91 | ], 92 | "type": "object", 93 | }, 94 | }, 95 | ], 96 | "text": "select * from users where id in $activeUsers(userOne, userTwo)", 97 | }, 98 | } 99 | `; 100 | 101 | exports[`scalar param 1`] = ` 102 | Object { 103 | "events": Array [], 104 | "query": Object { 105 | "name": "query", 106 | "params": Array [ 107 | Object { 108 | "location": Object { 109 | "a": 31, 110 | "b": 33, 111 | "col": 31, 112 | "line": 1, 113 | }, 114 | "name": "id", 115 | "required": false, 116 | "selection": Object { 117 | "type": "scalar", 118 | }, 119 | }, 120 | Object { 121 | "location": Object { 122 | "a": 46, 123 | "b": 51, 124 | "col": 46, 125 | "line": 1, 126 | }, 127 | "name": "title", 128 | "required": false, 129 | "selection": Object { 130 | "type": "scalar", 131 | }, 132 | }, 133 | ], 134 | "text": "select * from users where id = $id and title= $title", 135 | }, 136 | } 137 | `; 138 | -------------------------------------------------------------------------------- /packages/parser/src/loader/typescript/grammar/QueryLexer.g4: -------------------------------------------------------------------------------- 1 | /* 2 | -- @name GetAllUsers 3 | -- @param userNames -> (...) 4 | -- @param user -> (name,age) 5 | -- @param users -> ((name,age)...) 6 | select * from $userNames; 7 | select * from $books $filterById; 8 | */ 9 | 10 | lexer grammar QueryLexer; 11 | 12 | tokens { ID } 13 | 14 | fragment QUOT: '\''; 15 | fragment ID: [a-zA-Z_][a-zA-Z_0-9]*; 16 | 17 | SID: ID -> type(ID); 18 | SINGULAR_PARAM_MARK: '$'; 19 | PLURAL_PARAM_MARK: '$$'; 20 | COMMA: ','; 21 | OB: '('; 22 | CB: ')'; 23 | WORD: [a-zA-Z_0-9]+; 24 | REQUIRED_MARK: '!'; 25 | SPECIAL: [\-+*/<>=~@#%^&|`?{}.[\]":]+; 26 | EOF_STATEMENT: ';'; 27 | WSL : [ \t\r\n]+ -> skip; 28 | // parse strings and recognize escaped quotes 29 | STRING: QUOT (QUOT | .*? ~([\\]) QUOT); 30 | -------------------------------------------------------------------------------- /packages/parser/src/loader/typescript/grammar/QueryParser.g4: -------------------------------------------------------------------------------- 1 | parser grammar QueryParser; 2 | 3 | options { tokenVocab = QueryLexer; } 4 | 5 | input 6 | : query EOF_STATEMENT? EOF 7 | ; 8 | 9 | query 10 | : ignored+ (param ignored*)* 11 | ; 12 | 13 | param 14 | : pickParam 15 | | arrayPickParam 16 | | scalarParam 17 | | arrayParam 18 | ; 19 | 20 | ignored: (ID | WORD | STRING | COMMA | OB | CB | SPECIAL | REQUIRED_MARK)+; 21 | 22 | scalarParam: SINGULAR_PARAM_MARK scalarParamName; 23 | 24 | pickParam: SINGULAR_PARAM_MARK paramName OB pickKey (COMMA pickKey)* COMMA? CB; 25 | 26 | arrayPickParam: PLURAL_PARAM_MARK paramName OB pickKey (COMMA pickKey)* COMMA? CB; 27 | 28 | arrayParam: PLURAL_PARAM_MARK scalarParamName; 29 | 30 | scalarParamName: ID REQUIRED_MARK?; 31 | 32 | paramName: ID; 33 | 34 | pickKey: ID REQUIRED_MARK?; 35 | -------------------------------------------------------------------------------- /packages/parser/src/loader/typescript/parser/QueryParserListener.ts: -------------------------------------------------------------------------------- 1 | // Generated from src/loader/typescript/grammar/QueryParser.g4 by ANTLR 4.9.0-SNAPSHOT 2 | 3 | 4 | import { ParseTreeListener } from "antlr4ts/tree/ParseTreeListener.js"; 5 | 6 | import { InputContext } from "./QueryParser.js"; 7 | import { QueryContext } from "./QueryParser.js"; 8 | import { ParamContext } from "./QueryParser.js"; 9 | import { IgnoredContext } from "./QueryParser.js"; 10 | import { ScalarParamContext } from "./QueryParser.js"; 11 | import { PickParamContext } from "./QueryParser.js"; 12 | import { ArrayPickParamContext } from "./QueryParser.js"; 13 | import { ArrayParamContext } from "./QueryParser.js"; 14 | import { ScalarParamNameContext } from "./QueryParser.js"; 15 | import { ParamNameContext } from "./QueryParser.js"; 16 | import { PickKeyContext } from "./QueryParser.js"; 17 | 18 | 19 | /** 20 | * This interface defines a complete listener for a parse tree produced by 21 | * `QueryParser`. 22 | */ 23 | export interface QueryParserListener extends ParseTreeListener { 24 | /** 25 | * Enter a parse tree produced by `QueryParser.input`. 26 | * @param ctx the parse tree 27 | */ 28 | enterInput?: (ctx: InputContext) => void; 29 | /** 30 | * Exit a parse tree produced by `QueryParser.input`. 31 | * @param ctx the parse tree 32 | */ 33 | exitInput?: (ctx: InputContext) => void; 34 | 35 | /** 36 | * Enter a parse tree produced by `QueryParser.query`. 37 | * @param ctx the parse tree 38 | */ 39 | enterQuery?: (ctx: QueryContext) => void; 40 | /** 41 | * Exit a parse tree produced by `QueryParser.query`. 42 | * @param ctx the parse tree 43 | */ 44 | exitQuery?: (ctx: QueryContext) => void; 45 | 46 | /** 47 | * Enter a parse tree produced by `QueryParser.param`. 48 | * @param ctx the parse tree 49 | */ 50 | enterParam?: (ctx: ParamContext) => void; 51 | /** 52 | * Exit a parse tree produced by `QueryParser.param`. 53 | * @param ctx the parse tree 54 | */ 55 | exitParam?: (ctx: ParamContext) => void; 56 | 57 | /** 58 | * Enter a parse tree produced by `QueryParser.ignored`. 59 | * @param ctx the parse tree 60 | */ 61 | enterIgnored?: (ctx: IgnoredContext) => void; 62 | /** 63 | * Exit a parse tree produced by `QueryParser.ignored`. 64 | * @param ctx the parse tree 65 | */ 66 | exitIgnored?: (ctx: IgnoredContext) => void; 67 | 68 | /** 69 | * Enter a parse tree produced by `QueryParser.scalarParam`. 70 | * @param ctx the parse tree 71 | */ 72 | enterScalarParam?: (ctx: ScalarParamContext) => void; 73 | /** 74 | * Exit a parse tree produced by `QueryParser.scalarParam`. 75 | * @param ctx the parse tree 76 | */ 77 | exitScalarParam?: (ctx: ScalarParamContext) => void; 78 | 79 | /** 80 | * Enter a parse tree produced by `QueryParser.pickParam`. 81 | * @param ctx the parse tree 82 | */ 83 | enterPickParam?: (ctx: PickParamContext) => void; 84 | /** 85 | * Exit a parse tree produced by `QueryParser.pickParam`. 86 | * @param ctx the parse tree 87 | */ 88 | exitPickParam?: (ctx: PickParamContext) => void; 89 | 90 | /** 91 | * Enter a parse tree produced by `QueryParser.arrayPickParam`. 92 | * @param ctx the parse tree 93 | */ 94 | enterArrayPickParam?: (ctx: ArrayPickParamContext) => void; 95 | /** 96 | * Exit a parse tree produced by `QueryParser.arrayPickParam`. 97 | * @param ctx the parse tree 98 | */ 99 | exitArrayPickParam?: (ctx: ArrayPickParamContext) => void; 100 | 101 | /** 102 | * Enter a parse tree produced by `QueryParser.arrayParam`. 103 | * @param ctx the parse tree 104 | */ 105 | enterArrayParam?: (ctx: ArrayParamContext) => void; 106 | /** 107 | * Exit a parse tree produced by `QueryParser.arrayParam`. 108 | * @param ctx the parse tree 109 | */ 110 | exitArrayParam?: (ctx: ArrayParamContext) => void; 111 | 112 | /** 113 | * Enter a parse tree produced by `QueryParser.scalarParamName`. 114 | * @param ctx the parse tree 115 | */ 116 | enterScalarParamName?: (ctx: ScalarParamNameContext) => void; 117 | /** 118 | * Exit a parse tree produced by `QueryParser.scalarParamName`. 119 | * @param ctx the parse tree 120 | */ 121 | exitScalarParamName?: (ctx: ScalarParamNameContext) => void; 122 | 123 | /** 124 | * Enter a parse tree produced by `QueryParser.paramName`. 125 | * @param ctx the parse tree 126 | */ 127 | enterParamName?: (ctx: ParamNameContext) => void; 128 | /** 129 | * Exit a parse tree produced by `QueryParser.paramName`. 130 | * @param ctx the parse tree 131 | */ 132 | exitParamName?: (ctx: ParamNameContext) => void; 133 | 134 | /** 135 | * Enter a parse tree produced by `QueryParser.pickKey`. 136 | * @param ctx the parse tree 137 | */ 138 | enterPickKey?: (ctx: PickKeyContext) => void; 139 | /** 140 | * Exit a parse tree produced by `QueryParser.pickKey`. 141 | * @param ctx the parse tree 142 | */ 143 | exitPickKey?: (ctx: PickKeyContext) => void; 144 | } 145 | 146 | -------------------------------------------------------------------------------- /packages/parser/src/loader/typescript/parser/QueryParserVisitor.ts: -------------------------------------------------------------------------------- 1 | // Generated from src/loader/typescript/grammar/QueryParser.g4 by ANTLR 4.9.0-SNAPSHOT 2 | 3 | 4 | import { ParseTreeVisitor } from "antlr4ts/tree/ParseTreeVisitor.js"; 5 | 6 | import { InputContext } from "./QueryParser.js"; 7 | import { QueryContext } from "./QueryParser.js"; 8 | import { ParamContext } from "./QueryParser.js"; 9 | import { IgnoredContext } from "./QueryParser.js"; 10 | import { ScalarParamContext } from "./QueryParser.js"; 11 | import { PickParamContext } from "./QueryParser.js"; 12 | import { ArrayPickParamContext } from "./QueryParser.js"; 13 | import { ArrayParamContext } from "./QueryParser.js"; 14 | import { ScalarParamNameContext } from "./QueryParser.js"; 15 | import { ParamNameContext } from "./QueryParser.js"; 16 | import { PickKeyContext } from "./QueryParser.js"; 17 | 18 | 19 | /** 20 | * This interface defines a complete generic visitor for a parse tree produced 21 | * by `QueryParser`. 22 | * 23 | * @param The return type of the visit operation. Use `void` for 24 | * operations with no return type. 25 | */ 26 | export interface QueryParserVisitor extends ParseTreeVisitor { 27 | /** 28 | * Visit a parse tree produced by `QueryParser.input`. 29 | * @param ctx the parse tree 30 | * @return the visitor result 31 | */ 32 | visitInput?: (ctx: InputContext) => Result; 33 | 34 | /** 35 | * Visit a parse tree produced by `QueryParser.query`. 36 | * @param ctx the parse tree 37 | * @return the visitor result 38 | */ 39 | visitQuery?: (ctx: QueryContext) => Result; 40 | 41 | /** 42 | * Visit a parse tree produced by `QueryParser.param`. 43 | * @param ctx the parse tree 44 | * @return the visitor result 45 | */ 46 | visitParam?: (ctx: ParamContext) => Result; 47 | 48 | /** 49 | * Visit a parse tree produced by `QueryParser.ignored`. 50 | * @param ctx the parse tree 51 | * @return the visitor result 52 | */ 53 | visitIgnored?: (ctx: IgnoredContext) => Result; 54 | 55 | /** 56 | * Visit a parse tree produced by `QueryParser.scalarParam`. 57 | * @param ctx the parse tree 58 | * @return the visitor result 59 | */ 60 | visitScalarParam?: (ctx: ScalarParamContext) => Result; 61 | 62 | /** 63 | * Visit a parse tree produced by `QueryParser.pickParam`. 64 | * @param ctx the parse tree 65 | * @return the visitor result 66 | */ 67 | visitPickParam?: (ctx: PickParamContext) => Result; 68 | 69 | /** 70 | * Visit a parse tree produced by `QueryParser.arrayPickParam`. 71 | * @param ctx the parse tree 72 | * @return the visitor result 73 | */ 74 | visitArrayPickParam?: (ctx: ArrayPickParamContext) => Result; 75 | 76 | /** 77 | * Visit a parse tree produced by `QueryParser.arrayParam`. 78 | * @param ctx the parse tree 79 | * @return the visitor result 80 | */ 81 | visitArrayParam?: (ctx: ArrayParamContext) => Result; 82 | 83 | /** 84 | * Visit a parse tree produced by `QueryParser.scalarParamName`. 85 | * @param ctx the parse tree 86 | * @return the visitor result 87 | */ 88 | visitScalarParamName?: (ctx: ScalarParamNameContext) => Result; 89 | 90 | /** 91 | * Visit a parse tree produced by `QueryParser.paramName`. 92 | * @param ctx the parse tree 93 | * @return the visitor result 94 | */ 95 | visitParamName?: (ctx: ParamNameContext) => Result; 96 | 97 | /** 98 | * Visit a parse tree produced by `QueryParser.pickKey`. 99 | * @param ctx the parse tree 100 | * @return the visitor result 101 | */ 102 | visitPickKey?: (ctx: PickKeyContext) => Result; 103 | } 104 | 105 | -------------------------------------------------------------------------------- /packages/parser/src/loader/typescript/query.test.ts: -------------------------------------------------------------------------------- 1 | import parse from './query'; 2 | 3 | test('scalar param', () => { 4 | const query = `select * from users where id = $id and title= $title`; 5 | 6 | const result = parse(query); 7 | expect(result).toMatchSnapshot(); 8 | }); 9 | 10 | test('pick param', () => { 11 | const query = `select * from users where id in $activeUsers(userOne, userTwo)`; 12 | 13 | const result = parse(query); 14 | expect(result).toMatchSnapshot(); 15 | }); 16 | 17 | test('array param', () => { 18 | const query = `select * from users where id in $$ids`; 19 | 20 | const result = parse(query); 21 | expect(result).toMatchSnapshot(); 22 | }); 23 | 24 | test('array spread param', () => { 25 | const query = `INSERT INTO customers (customer_name, contact_name, address) 26 | VALUES $$customers(customerName, contactName, address)`; 27 | 28 | const result = parse(query); 29 | expect(result).toMatchSnapshot(); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/parser/src/loader/typescript/query.ts: -------------------------------------------------------------------------------- 1 | import { QueryParserListener } from './parser/QueryParserListener.js'; 2 | import { CharStreams, CommonTokenStream } from 'antlr4ts'; 3 | import { ParseTreeWalker } from 'antlr4ts/tree/ParseTreeWalker.js'; 4 | import { QueryLexer } from './parser/QueryLexer.js'; 5 | import { 6 | ParamContext, 7 | ParamNameContext, 8 | PickKeyContext, 9 | QueryContext, 10 | QueryParser, 11 | ScalarParamNameContext, 12 | } from './parser/QueryParser.js'; 13 | import { Logger, ParseEvent } from '../sql/logger.js'; 14 | import { Interval } from 'antlr4ts/misc/index.js'; 15 | 16 | export enum ParamType { 17 | Scalar = 'scalar', 18 | Object = 'object', 19 | ScalarArray = 'scalar_array', 20 | ObjectArray = 'object_array', 21 | } 22 | 23 | export interface ParamKey { 24 | name: string; 25 | required: boolean; 26 | } 27 | 28 | export type ParamSelection = 29 | | { 30 | type: ParamType.Scalar; 31 | } 32 | | { 33 | type: ParamType.ScalarArray; 34 | } 35 | | { 36 | type: ParamType.Object | ParamType.ObjectArray; 37 | keys: ParamKey[]; 38 | }; 39 | 40 | export interface Param { 41 | name: string; 42 | selection: ParamSelection; 43 | required: boolean; 44 | location: CodeInterval; 45 | } 46 | 47 | interface CodeInterval { 48 | a: number; 49 | b: number; 50 | line: number; 51 | col: number; 52 | } 53 | 54 | export interface Query { 55 | name: string; 56 | params: Param[]; 57 | text: string; 58 | } 59 | 60 | export function assert(condition: any): asserts condition { 61 | if (!condition) { 62 | throw new Error('Assertion Failed'); 63 | } 64 | } 65 | 66 | class ParseListener implements QueryParserListener { 67 | logger: Logger; 68 | query: Partial = {}; 69 | private currentParam: Partial = {}; 70 | private currentSelection: Partial = {}; 71 | 72 | constructor(queryName: string, logger: Logger) { 73 | this.query.name = queryName; 74 | this.logger = logger; 75 | } 76 | 77 | enterQuery(ctx: QueryContext) { 78 | const { inputStream } = ctx.start; 79 | const end = ctx.stop!.stopIndex; 80 | 81 | const interval = new Interval(0, end); 82 | const text = inputStream!.getText(interval); 83 | this.query = { 84 | name: this.query.name, 85 | text, 86 | params: [], 87 | }; 88 | } 89 | 90 | enterParamName(ctx: ParamNameContext) { 91 | this.currentParam = { 92 | name: ctx.text, 93 | selection: undefined, 94 | }; 95 | } 96 | 97 | enterScalarParamName(ctx: ScalarParamNameContext) { 98 | const required = !!ctx.REQUIRED_MARK(); 99 | const name = ctx.ID().text; 100 | 101 | this.currentParam = { 102 | name, 103 | required, 104 | }; 105 | } 106 | 107 | exitParam(ctx: ParamContext) { 108 | const defLoc = { 109 | a: ctx.start.startIndex, 110 | b: ctx.stop!.stopIndex, 111 | line: ctx.start.line, 112 | col: ctx.start.charPositionInLine, 113 | }; 114 | this.currentParam.location = defLoc; 115 | this.currentParam.selection = this.currentSelection as ParamSelection; 116 | this.query.params!.push(this.currentParam as Param); 117 | this.currentSelection = {}; 118 | this.currentParam = {}; 119 | } 120 | 121 | enterScalarParam() { 122 | this.currentSelection = { 123 | type: ParamType.Scalar, 124 | }; 125 | } 126 | 127 | enterPickParam() { 128 | this.currentSelection = { 129 | type: ParamType.Object, 130 | keys: [], 131 | }; 132 | } 133 | 134 | enterArrayPickParam() { 135 | this.currentSelection = { 136 | type: ParamType.ObjectArray, 137 | keys: [], 138 | }; 139 | } 140 | 141 | enterArrayParam() { 142 | this.currentSelection = { 143 | type: ParamType.ScalarArray, 144 | }; 145 | } 146 | 147 | enterPickKey(ctx: PickKeyContext) { 148 | assert('keys' in this.currentSelection); 149 | 150 | const required = !!ctx.REQUIRED_MARK(); 151 | const name = ctx.ID().text; 152 | 153 | this.currentSelection.keys!.push({ name, required }); 154 | } 155 | } 156 | 157 | function parseText( 158 | text: string, 159 | queryName: string = 'query', 160 | ): { query: Query; events: ParseEvent[] } { 161 | const logger = new Logger(); 162 | const inputStream = CharStreams.fromString(text); 163 | const lexer = new QueryLexer(inputStream); 164 | lexer.removeErrorListeners(); 165 | lexer.addErrorListener(logger); 166 | const tokenStream = new CommonTokenStream(lexer); 167 | const parser = new QueryParser(tokenStream); 168 | parser.removeErrorListeners(); 169 | parser.addErrorListener(logger); 170 | 171 | const tree = parser.input(); 172 | 173 | const listener = new ParseListener(queryName, logger); 174 | ParseTreeWalker.DEFAULT.walk(listener as QueryParserListener, tree); 175 | 176 | return { 177 | query: listener.query as any, 178 | events: logger.parseEvents, 179 | }; 180 | } 181 | 182 | export default parseText; 183 | -------------------------------------------------------------------------------- /packages/parser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib/", 5 | "rootDir": "./src/" 6 | }, 7 | "exclude": ["lib", "**/*.test.ts", "jest.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/query/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib -------------------------------------------------------------------------------- /packages/query/README.md: -------------------------------------------------------------------------------- 1 | ## @pgtyped/query 2 | 3 | This package provides protocol utilities for PgTyped queries. 4 | 5 | This package is part of the pgtyped project. 6 | Refer to [README](https://github.com/adelsz/pgtyped) for details. 7 | -------------------------------------------------------------------------------- /packages/query/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | snapshotFormat: { 5 | escapeString: true, 6 | printBasicPrototype: true, 7 | }, 8 | roots: ['src'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | useESM: true, 17 | }, 18 | ], 19 | }, 20 | preset: 'ts-jest/presets/default-esm', 21 | testRegex: '\\.test\\.tsx?$', 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /packages/query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pgtyped-rescript-query", 3 | "version": "2.4.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "import": "./lib/index.js", 8 | "types": "./lib/index.d.ts" 9 | } 10 | }, 11 | "main": "lib/index.js", 12 | "types": "lib/index.d.ts", 13 | "files": [ 14 | "lib" 15 | ], 16 | "engines": { 17 | "node": ">=14.16" 18 | }, 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/adelsz/pgtyped.git" 23 | }, 24 | "homepage": "https://github.com/adelsz/pgtyped", 25 | "publishConfig": { 26 | "access": "public" 27 | }, 28 | "scripts": { 29 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", 30 | "build": "tsc", 31 | "check": "tsc --noEmit", 32 | "watch": "tsc --watch --preserveWatchOutput" 33 | }, 34 | "dependencies": { 35 | "pgtyped-rescript-runtime": "^2.2.0", 36 | "@pgtyped/wire": "^2.2.0", 37 | "chalk": "^4.1.0", 38 | "debug": "^4.1.1" 39 | }, 40 | "devDependencies": { 41 | "@types/chalk": "^2.2.0", 42 | "@types/debug": "^4.1.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/query/src/__snapshots__/actions.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`reduce type rows to MappableTypes 1`] = ` 4 | Object { 5 | "23": "int4", 6 | "25": "text", 7 | } 8 | `; 9 | 10 | exports[`reduce type rows to MappableTypes 2`] = ` 11 | Object { 12 | "16398": Object { 13 | "enumValues": Array [ 14 | "notification", 15 | "reminder", 16 | "deadline", 17 | ], 18 | "name": "notification_type", 19 | }, 20 | "23": "int4", 21 | "3802": "jsonb", 22 | } 23 | `; 24 | 25 | exports[`reduce type rows to MappableTypes 3`] = ` 26 | Object { 27 | "16398": Object { 28 | "enumValues": Array [ 29 | "notification", 30 | "reminder", 31 | "deadline", 32 | ], 33 | "name": "notification_type", 34 | }, 35 | "23": "int4", 36 | "3802": "jsonb", 37 | } 38 | `; 39 | 40 | exports[`reduce type rows to MappableTypes 4`] = ` 41 | Object { 42 | "16398": Object { 43 | "enumValues": Array [ 44 | "notification", 45 | "reminder", 46 | "deadline", 47 | ], 48 | "name": "notification_type", 49 | }, 50 | "23": "int4", 51 | "25": "text", 52 | "3802": "jsonb", 53 | } 54 | `; 55 | 56 | exports[`reduce type rows to MappableTypes 5`] = ` 57 | Object { 58 | "1082": "date", 59 | "20": "int8", 60 | "23": "int4", 61 | "25": "text", 62 | } 63 | `; 64 | 65 | exports[`reduce type rows to MappableTypes 6`] = ` 66 | Object { 67 | "16398": Object { 68 | "enumValues": Array [ 69 | "notification", 70 | "reminder", 71 | "deadline", 72 | ], 73 | "name": "notification_type", 74 | }, 75 | "24": Object { 76 | "elementType": Object { 77 | "enumValues": Array [ 78 | "notification", 79 | "reminder", 80 | "deadline", 81 | ], 82 | "name": "notification_type", 83 | }, 84 | "name": "_enum", 85 | }, 86 | } 87 | `; 88 | -------------------------------------------------------------------------------- /packages/query/src/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { generateHash, reduceTypeRows } from './actions.js'; 2 | 3 | test('test postgres md5 hash generation', () => { 4 | const salt = [0x81, 0xcc, 0x95, 0x8b]; 5 | const result = generateHash('test', 'example', Buffer.from(salt)); 6 | expect(result).toEqual('md5b73f398d18e98f8e2d46a7f1c548dea3'); 7 | }); 8 | 9 | test('reduce type rows to MappableTypes', () => { 10 | expect( 11 | reduceTypeRows([ 12 | { 13 | oid: '25', 14 | typeName: 'text', 15 | typeKind: 'b', 16 | enumLabel: '', 17 | }, 18 | { 19 | oid: '23', 20 | typeName: 'int4', 21 | typeKind: 'b', 22 | enumLabel: '', 23 | }, 24 | ]), 25 | ).toMatchSnapshot(); 26 | 27 | expect( 28 | reduceTypeRows([ 29 | { 30 | oid: '16398', 31 | typeName: 'notification_type', 32 | typeKind: 'e', 33 | enumLabel: 'notification', 34 | }, 35 | { 36 | oid: '16398', 37 | typeName: 'notification_type', 38 | typeKind: 'e', 39 | enumLabel: 'reminder', 40 | }, 41 | { 42 | oid: '16398', 43 | typeName: 'notification_type', 44 | typeKind: 'e', 45 | enumLabel: 'deadline', 46 | }, 47 | { 48 | oid: '23', 49 | typeName: 'int4', 50 | typeKind: 'b', 51 | enumLabel: '', 52 | }, 53 | { 54 | oid: '3802', 55 | typeName: 'jsonb', 56 | typeKind: 'b', 57 | enumLabel: '', 58 | }, 59 | ]), 60 | ).toMatchSnapshot(); 61 | 62 | expect( 63 | reduceTypeRows([ 64 | { 65 | oid: '16398', 66 | typeName: 'notification_type', 67 | typeKind: 'e', 68 | enumLabel: 'notification', 69 | }, 70 | { 71 | oid: '16398', 72 | typeName: 'notification_type', 73 | typeKind: 'e', 74 | enumLabel: 'reminder', 75 | }, 76 | { 77 | oid: '16398', 78 | typeName: 'notification_type', 79 | typeKind: 'e', 80 | enumLabel: 'deadline', 81 | }, 82 | { 83 | oid: '23', 84 | typeName: 'int4', 85 | typeKind: 'b', 86 | enumLabel: '', 87 | }, 88 | { 89 | oid: '3802', 90 | typeName: 'jsonb', 91 | typeKind: 'b', 92 | enumLabel: '', 93 | }, 94 | ]), 95 | ).toMatchSnapshot(); 96 | 97 | expect( 98 | reduceTypeRows([ 99 | { 100 | oid: '16398', 101 | typeName: 'notification_type', 102 | typeKind: 'e', 103 | enumLabel: 'notification', 104 | }, 105 | { 106 | oid: '16398', 107 | typeName: 'notification_type', 108 | typeKind: 'e', 109 | enumLabel: 'reminder', 110 | }, 111 | { 112 | oid: '16398', 113 | typeName: 'notification_type', 114 | typeKind: 'e', 115 | enumLabel: 'deadline', 116 | }, 117 | { 118 | oid: '25', 119 | typeName: 'text', 120 | typeKind: 'b', 121 | enumLabel: '', 122 | }, 123 | { 124 | oid: '23', 125 | typeName: 'int4', 126 | typeKind: 'b', 127 | enumLabel: '', 128 | }, 129 | { 130 | oid: '3802', 131 | typeName: 'jsonb', 132 | typeKind: 'b', 133 | enumLabel: '', 134 | }, 135 | ]), 136 | ).toMatchSnapshot(); 137 | 138 | expect( 139 | reduceTypeRows([ 140 | { 141 | oid: '20', 142 | typeName: 'int8', 143 | typeKind: 'b', 144 | enumLabel: '', 145 | }, 146 | { 147 | oid: '25', 148 | typeName: 'text', 149 | typeKind: 'b', 150 | enumLabel: '', 151 | }, 152 | { 153 | oid: '1082', 154 | typeName: 'date', 155 | typeKind: 'b', 156 | enumLabel: '', 157 | }, 158 | { 159 | oid: '23', 160 | typeName: 'int4', 161 | typeKind: 'b', 162 | enumLabel: '', 163 | }, 164 | ]), 165 | ).toMatchSnapshot(); 166 | 167 | expect( 168 | reduceTypeRows([ 169 | { 170 | oid: '16398', 171 | typeName: 'notification_type', 172 | typeKind: 'e', 173 | enumLabel: 'notification', 174 | }, 175 | { 176 | oid: '16398', 177 | typeName: 'notification_type', 178 | typeKind: 'e', 179 | enumLabel: 'reminder', 180 | }, 181 | { 182 | oid: '16398', 183 | typeName: 'notification_type', 184 | typeKind: 'e', 185 | enumLabel: 'deadline', 186 | }, 187 | { 188 | oid: '24', 189 | typeName: '_enum', 190 | typeKind: 'b', 191 | enumLabel: '', 192 | typeCategory: 'A' as any, 193 | elementTypeOid: '16398', 194 | }, 195 | ]), 196 | ).toMatchSnapshot(); 197 | }); 198 | -------------------------------------------------------------------------------- /packages/query/src/index.ts: -------------------------------------------------------------------------------- 1 | import { InterpolatedQuery } from 'pgtyped-rescript-runtime'; 2 | import { IParseError, IQueryTypes } from './actions.js'; 3 | 4 | export { getTypes, startup, IParseError, IQueryTypes } from './actions.js'; 5 | 6 | export { 7 | isAlias, 8 | isEnum, 9 | isEnumArray, 10 | isImport, 11 | MappableType, 12 | Type, 13 | ImportedType, 14 | } from './type.js'; 15 | 16 | export type TypeSource = ( 17 | queryData: InterpolatedQuery, 18 | ) => Promise; 19 | -------------------------------------------------------------------------------- /packages/query/src/sasl-helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | checkServerFinalMessage, 3 | createClientSASLContinueResponse, 4 | createInitialSASLResponse, 5 | } from './sasl-helpers.js'; 6 | 7 | test('createInitialSASLResponse', () => { 8 | const { clientNonce, response } = createInitialSASLResponse(); 9 | expect(clientNonce.length).toEqual(24); 10 | expect(response).toMatch(/^n,,n=\*,r=.{24}/); 11 | }); 12 | 13 | test('createInitialSASLResponse creates random nonces', () => { 14 | const { clientNonce: nonce1 } = createInitialSASLResponse(); 15 | const { clientNonce: nonce2 } = createInitialSASLResponse(); 16 | expect(nonce1).not.toEqual(nonce2); 17 | }); 18 | 19 | test('createClientSASLContinueResponse to fail when not giving it correct SASLData', () => { 20 | expect(() => createClientSASLContinueResponse('', '', '')).toThrowError(); 21 | }); 22 | test('createClientSASLContinueResponse to fail when nonce is missing in SASLData', () => { 23 | expect(() => 24 | createClientSASLContinueResponse('', '', 's=1,i=1'), 25 | ).toThrowError('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing'); 26 | }); 27 | test('createClientSASLContinueResponse to fail when salt is missing in SASLata', () => { 28 | expect(() => 29 | createClientSASLContinueResponse('', '', 'r=1,i=1'), 30 | ).toThrowError('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing'); 31 | }); 32 | test('createClientSASLContinueResponse to fail when iteration is missing in SASLata', () => { 33 | expect(() => 34 | createClientSASLContinueResponse('', '', 'r=1,s=abcd'), 35 | ).toThrowError('SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing'); 36 | }); 37 | test('createClientSASLContinueResponse to fail when SASLData does not contain client nonce in server nonce', () => { 38 | expect(() => 39 | createClientSASLContinueResponse('password', '2', 'r=1,s=abcd,i=1'), 40 | ).toThrowError( 41 | 'SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce', 42 | ); 43 | }); 44 | test('createClientSASLContinueResponse works as expected', () => { 45 | const clientNonce = 'a'; 46 | const { response, calculatedServerSignature } = 47 | createClientSASLContinueResponse( 48 | 'password', 49 | clientNonce, 50 | 'r=ab,s=abcd,i=1', 51 | ); 52 | expect(response).toEqual( 53 | 'c=biws,r=ab,p=mU8grLfTjDrJer9ITsdHk0igMRDejG10EJPFbIBL3D0=', 54 | ); 55 | expect(calculatedServerSignature).toEqual( 56 | 'jwt97IHWFn7FEqHykPTxsoQrKGOMXJl/PJyJ1JXTBKc=', 57 | ); 58 | }); 59 | test('checkServerFinalMessage is failing when server signature is missing', () => { 60 | expect(() => checkServerFinalMessage('', 'abcd')).toThrowError( 61 | 'SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature is missing', 62 | ); 63 | }); 64 | 65 | test('checkServerFinalMessage is failing when server signature is not base64', () => { 66 | expect(() => checkServerFinalMessage('v=x1', 'abcd')).toThrowError( 67 | 'SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature must be base64', 68 | ); 69 | }); 70 | 71 | test( 72 | 'checkServerFinalMessage is failing when server signature does not match calculated server signature at client' + 73 | ' side', 74 | () => { 75 | expect(() => checkServerFinalMessage('v=xyzq', 'abcd')).toThrowError( 76 | 'SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match', 77 | ); 78 | }, 79 | ); 80 | 81 | test('checkServerFinalMessage does not throw an error when it should suppose to work', () => { 82 | expect(() => checkServerFinalMessage('v=abcd', 'abcd')).not.toThrowError(); 83 | }); 84 | -------------------------------------------------------------------------------- /packages/query/src/sasl-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SASL-helpers for authentication using SASL 3 | */ 4 | import { cString } from '@pgtyped/wire'; 5 | import crypto from 'crypto'; 6 | 7 | export function createInitialSASLResponse(): { 8 | response: string; 9 | clientNonce: string; 10 | } { 11 | const clientNonce = crypto.randomBytes(18).toString('base64'); 12 | return { response: 'n,,n=*,r=' + clientNonce, clientNonce }; 13 | } 14 | 15 | export function createClientSASLContinueResponse( 16 | password: string, 17 | clientNonce: string, 18 | SASLData: string, 19 | ): { response: string; calculatedServerSignature: string } { 20 | const SASLContinueServerVariables = 21 | extractVariablesFromSASLContinueServerMessage(SASLData); 22 | 23 | if (!SASLContinueServerVariables.nonce.startsWith(clientNonce)) { 24 | throw new Error( 25 | 'SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce', 26 | ); 27 | } 28 | 29 | const passwordBytes = cString(password); 30 | 31 | const saltBytes = Buffer.from(SASLContinueServerVariables.salt, 'base64'); 32 | const saltedPassword = Hi( 33 | passwordBytes, 34 | saltBytes, 35 | SASLContinueServerVariables.iteration, 36 | ); 37 | 38 | const clientKey = createHMAC(saltedPassword, 'Client Key'); 39 | const storedKey = crypto.createHash('sha256').update(clientKey).digest(); 40 | 41 | const clientFirstMessageBare = 'n=*,r=' + clientNonce; 42 | const serverFirstMessage = 43 | 'r=' + 44 | SASLContinueServerVariables.nonce + 45 | ',s=' + 46 | SASLContinueServerVariables.salt + 47 | ',i=' + 48 | SASLContinueServerVariables.iteration; 49 | 50 | const clientFinalMessageWithoutProof = 51 | 'c=biws,r=' + SASLContinueServerVariables.nonce; 52 | 53 | const authMessage = 54 | clientFirstMessageBare + 55 | ',' + 56 | serverFirstMessage + 57 | ',' + 58 | clientFinalMessageWithoutProof; 59 | 60 | const clientSignature = createHMAC(storedKey, authMessage); 61 | const clientProofBytes = xorBuffers(clientKey, clientSignature); 62 | const clientProof = clientProofBytes.toString('base64'); 63 | 64 | const serverKey = createHMAC(saltedPassword, 'Server Key'); 65 | const serverSignatureBytes = createHMAC(serverKey, authMessage); 66 | 67 | const calculatedServerSignature = serverSignatureBytes.toString('base64'); 68 | 69 | return { 70 | response: clientFinalMessageWithoutProof + ',p=' + clientProof, 71 | calculatedServerSignature, 72 | }; 73 | } 74 | 75 | export function checkServerFinalMessage( 76 | serverData: string, 77 | calculatedServerSignature: string, 78 | ) { 79 | const attrPairs = parseAttributePairs(serverData); 80 | const serverSignatureFromServer = attrPairs.get('v'); 81 | if (!serverSignatureFromServer) { 82 | throw new Error( 83 | 'SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature is missing', 84 | ); 85 | } else if (!isBase64(serverSignatureFromServer)) { 86 | throw new Error( 87 | 'SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature must be base64', 88 | ); 89 | } 90 | 91 | if (calculatedServerSignature !== serverSignatureFromServer) { 92 | throw new Error( 93 | 'SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match', 94 | ); 95 | } 96 | } 97 | 98 | function extractVariablesFromSASLContinueServerMessage(data: string): { 99 | nonce: string; 100 | salt: string; 101 | iteration: number; 102 | } { 103 | let nonce: string | undefined; 104 | let salt: string | undefined; 105 | let iteration: number | undefined; 106 | 107 | String(data) 108 | .split(',') 109 | .forEach((part) => { 110 | switch (part[0]) { 111 | case 'r': 112 | nonce = part.substr(2); 113 | break; 114 | case 's': 115 | salt = part.substr(2); 116 | break; 117 | case 'i': 118 | iteration = parseInt(part.substr(2), 10); 119 | break; 120 | } 121 | }); 122 | 123 | if (!nonce) { 124 | throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing'); 125 | } 126 | 127 | if (!salt) { 128 | throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing'); 129 | } 130 | 131 | if (!iteration) { 132 | throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing'); 133 | } 134 | 135 | return { 136 | nonce, 137 | salt, 138 | iteration, 139 | }; 140 | } 141 | 142 | /* tslint:disable:no-bitwise */ 143 | function xorBuffers(a: Buffer, b: Buffer): Buffer { 144 | if (!Buffer.isBuffer(a)) a = Buffer.from(a); 145 | if (!Buffer.isBuffer(b)) b = Buffer.from(b); 146 | const res = []; 147 | if (a.length > b.length) { 148 | for (let i = 0; i < b.length; i++) { 149 | res.push(a[i] ^ b[i]); 150 | } 151 | } else { 152 | for (let j = 0; j < a.length; j++) { 153 | res.push(a[j] ^ b[j]); 154 | } 155 | } 156 | return Buffer.from(res); 157 | } 158 | /* tslint:enable:no-bitwise */ 159 | function createHMAC(key: Buffer, msg: string | Buffer) { 160 | return crypto.createHmac('sha256', key).update(msg).digest(); 161 | } 162 | 163 | function Hi(password: Buffer, saltBytes: Buffer, iterations: number) { 164 | let ui1 = createHMAC( 165 | password, 166 | Buffer.concat([saltBytes, Buffer.from([0, 0, 0, 1])]), 167 | ); 168 | let ui = ui1; 169 | for (let i = 0; i < iterations - 1; i++) { 170 | ui1 = createHMAC(password, ui1); 171 | ui = xorBuffers(ui, ui1); 172 | } 173 | 174 | return ui; 175 | } 176 | 177 | function isBase64(text: string) { 178 | return /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/.test( 179 | text, 180 | ); 181 | } 182 | 183 | function parseAttributePairs(text: string) { 184 | return new Map( 185 | text 186 | .split(',') 187 | .filter((attrValue) => /^.=./.test(attrValue)) 188 | .map((attrValue) => { 189 | const name = attrValue[0]; 190 | const value = attrValue.substring(2); 191 | return [name, value]; 192 | }), 193 | ); 194 | } 195 | -------------------------------------------------------------------------------- /packages/query/src/type.ts: -------------------------------------------------------------------------------- 1 | export type Type = 2 | | NamedType 3 | | ImportedType 4 | | AliasedType 5 | | EnumType 6 | | EnumArrayType; 7 | // May be a database source type name (string) or a typescript destination type (Type) 8 | export type MappableType = string | Type; 9 | 10 | export interface NamedType { 11 | name: string; 12 | definition?: string; 13 | enumValues?: string[]; 14 | } 15 | 16 | export interface ImportedType extends NamedType { 17 | from: string; 18 | aliasOf?: string; 19 | } 20 | 21 | export interface AliasedType extends NamedType { 22 | definition: string; 23 | } 24 | 25 | export interface EnumType extends NamedType { 26 | enumValues: string[]; 27 | } 28 | 29 | export interface EnumArrayType extends NamedType { 30 | name: string; 31 | elementType: EnumType; 32 | } 33 | 34 | export function isImport(typ: Type): typ is ImportedType { 35 | return 'from' in typ; 36 | } 37 | 38 | export function isAlias(typ: Type): typ is AliasedType { 39 | return 'definition' in typ; 40 | } 41 | 42 | export function isEnum(typ: MappableType): typ is EnumType { 43 | return typeof typ !== 'string' && 'enumValues' in typ; 44 | } 45 | 46 | export function isEnumArray(typ: MappableType): typ is EnumArrayType { 47 | return typeof typ !== 'string' && 'elementType' in typ; 48 | } 49 | 50 | export const enum DatabaseTypeKind { 51 | Base = 'b', 52 | Composite = 'c', 53 | Domain = 'd', 54 | Enum = 'e', 55 | Pseudo = 'p', 56 | Range = 'r', 57 | } 58 | -------------------------------------------------------------------------------- /packages/query/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib/", 5 | "rootDir": "./src/", 6 | }, 7 | "exclude": ["lib", "**/*.test.ts", "jest.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/runtime/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib -------------------------------------------------------------------------------- /packages/runtime/README.md: -------------------------------------------------------------------------------- 1 | ## @pgtyped/runtime 2 | 3 | This package provides the `sql` tagged template. 4 | The `sql` tagged template requires a generic parameter: ``. 5 | For each query PgTyped generates an interface that can be used in this parameter to type your query. 6 | 7 | To run a query defined with the `sql` tagged template, call the `sql.run` method. 8 | The `sql.run` method automatically enforces correct input `TParams` and output `TResult` types. 9 | 10 | ```js 11 | public run: ( 12 | params: TParams, 13 | dbConnection: IDatabaseConnection, 14 | ) => Promise; 15 | ``` 16 | 17 | Here `dbConnection` is any object that satisifies the `IDatabaseConnection` interface. It is used to actually send the query to the DB for execution. 18 | 19 | ``` 20 | interface IDatabaseConnection { 21 | query: (query: string, bindings: any[]) => Promise<{ rows: any[] }>; 22 | } 23 | ``` 24 | 25 | This is usually the `client` object created with [node-postgres](https://github.com/brianc/node-postgres), but can be any other connection of your choice. 26 | 27 | This package is part of the pgtyped project. 28 | Refer to [README](https://github.com/adelsz/pgtyped) for details. 29 | -------------------------------------------------------------------------------- /packages/runtime/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | snapshotFormat: { 5 | escapeString: true, 6 | printBasicPrototype: true, 7 | }, 8 | roots: ['src'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | useESM: true, 17 | }, 18 | ], 19 | }, 20 | preset: 'ts-jest/presets/default-esm', 21 | testRegex: '\\.test\\.tsx?$', 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /packages/runtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pgtyped-rescript-runtime", 3 | "version": "2.2.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "types": "./lib/index.d.ts", 8 | "import": "./lib/index.js", 9 | "require": "./lib/index.cjs", 10 | "default": "./lib/index.js" 11 | } 12 | }, 13 | "types": "lib/index.d.ts", 14 | "files": [ 15 | "lib" 16 | ], 17 | "engines": { 18 | "node": ">=14.16" 19 | }, 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/adelsz/pgtyped.git" 24 | }, 25 | "homepage": "https://github.com/adelsz/pgtyped", 26 | "publishConfig": { 27 | "access": "public" 28 | }, 29 | "scripts": { 30 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", 31 | "build:cjs": "esbuild --bundle --sourcemap --platform=node --target=node14 src/index.ts --minify --external:chalk --external:antlr4ts --outfile=lib/index.cjs", 32 | "build": "tsc && npm run build:cjs", 33 | "check": "tsc --noEmit", 34 | "watch": "tsc --watch --preserveWatchOutput" 35 | }, 36 | "dependencies": { 37 | "@pgtyped/parser": "^2.1.0", 38 | "chalk": "^4.1.0", 39 | "debug": "^4.1.1" 40 | }, 41 | "devDependencies": { 42 | "@types/chalk": "^2.2.0", 43 | "@types/debug": "^4.1.4", 44 | "esbuild": "^0.18.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/runtime/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ParameterTransform, 3 | QueryParameters, 4 | InterpolatedQuery, 5 | QueryParameter, 6 | } from './preprocessor.js'; 7 | 8 | export { processTSQueryAST } from './preprocessor-ts.js'; 9 | export { processSQLQueryIR } from './preprocessor-sql.js'; 10 | 11 | export { sql, TaggedQuery, PreparedQuery } from './tag.js'; 12 | -------------------------------------------------------------------------------- /packages/runtime/src/preprocessor-sql.ts: -------------------------------------------------------------------------------- 1 | import { assert, SQLQueryIR, TransformType } from '@pgtyped/parser'; 2 | import { 3 | InterpolatedQuery, 4 | NestedParameters, 5 | QueryParameters, 6 | ScalarArrayParameter, 7 | ScalarParameter, 8 | ParameterTransform, 9 | QueryParameter, 10 | replaceIntervals, 11 | Scalar, 12 | } from './preprocessor.js'; 13 | 14 | /* Processes query AST formed by new parser from pure SQL files */ 15 | export const processSQLQueryIR = ( 16 | queryIR: SQLQueryIR, 17 | passedParams?: QueryParameters, 18 | ): InterpolatedQuery => { 19 | const bindings: Scalar[] = []; 20 | const paramMapping: QueryParameter[] = []; 21 | const usedParams = queryIR.params.filter( 22 | (p) => p.name in queryIR.usedParamSet, 23 | ); 24 | let i = 1; 25 | const intervals: { a: number; b: number; sub: string }[] = []; 26 | for (const usedParam of usedParams) { 27 | // Handle spread transform 28 | if (usedParam.transform.type === TransformType.ArraySpread) { 29 | let sub: string; 30 | if (passedParams) { 31 | const paramValue = passedParams[usedParam.name]; 32 | sub = (paramValue as Scalar[]) 33 | .map((val) => { 34 | bindings.push(val); 35 | return `$${i++}`; 36 | }) 37 | .join(','); 38 | } else { 39 | const idx = i++; 40 | paramMapping.push({ 41 | name: usedParam.name, 42 | type: ParameterTransform.Spread, 43 | assignedIndex: idx, 44 | required: usedParam.required, 45 | } as ScalarArrayParameter); 46 | sub = `$${idx}`; 47 | } 48 | usedParam.locs.forEach((loc) => 49 | intervals.push({ 50 | ...loc, 51 | sub: `(${sub})`, 52 | }), 53 | ); 54 | continue; 55 | } 56 | 57 | // Handle pick transform 58 | if (usedParam.transform.type === TransformType.PickTuple) { 59 | const dict: { 60 | [key: string]: ScalarParameter; 61 | } = {}; 62 | const sub = usedParam.transform.keys 63 | .map(({ name, required }) => { 64 | const idx = i++; 65 | dict[name] = { 66 | name, 67 | required, 68 | type: ParameterTransform.Scalar, 69 | assignedIndex: idx, 70 | } as ScalarParameter; 71 | if (passedParams) { 72 | const paramValue = passedParams[usedParam.name] as NestedParameters; 73 | const val = paramValue[name]; 74 | bindings.push(val); 75 | } 76 | return `$${idx}`; 77 | }) 78 | .join(','); 79 | if (!passedParams) { 80 | paramMapping.push({ 81 | name: usedParam.name, 82 | type: ParameterTransform.Pick, 83 | dict, 84 | }); 85 | } 86 | 87 | usedParam.locs.forEach((loc) => 88 | intervals.push({ 89 | ...loc, 90 | sub: `(${sub})`, 91 | }), 92 | ); 93 | continue; 94 | } 95 | 96 | // Handle spreadPick transform 97 | if (usedParam.transform.type === TransformType.PickArraySpread) { 98 | let sub: string; 99 | if (passedParams) { 100 | const passedParam = passedParams[usedParam.name] as NestedParameters[]; 101 | sub = passedParam 102 | .map((entity) => { 103 | assert(usedParam.transform.type === TransformType.PickArraySpread); 104 | const ssub = usedParam.transform.keys 105 | .map(({ name }) => { 106 | const val = entity[name]; 107 | bindings.push(val); 108 | return `$${i++}`; 109 | }) 110 | .join(','); 111 | return ssub; 112 | }) 113 | .join('),('); 114 | } else { 115 | const dict: { 116 | [key: string]: ScalarParameter; 117 | } = {}; 118 | sub = usedParam.transform.keys 119 | .map(({ name, required }) => { 120 | const idx = i++; 121 | dict[name] = { 122 | name, 123 | required, 124 | type: ParameterTransform.Scalar, 125 | assignedIndex: idx, 126 | } as ScalarParameter; 127 | return `$${idx}`; 128 | }) 129 | .join(','); 130 | paramMapping.push({ 131 | name: usedParam.name, 132 | type: ParameterTransform.PickSpread, 133 | dict, 134 | }); 135 | } 136 | 137 | usedParam.locs.forEach((loc) => 138 | intervals.push({ 139 | ...loc, 140 | sub: `(${sub})`, 141 | }), 142 | ); 143 | continue; 144 | } 145 | 146 | // Handle scalar transform 147 | const assignedIndex = i++; 148 | if (passedParams) { 149 | const paramValue = passedParams[usedParam.name] as Scalar; 150 | bindings.push(paramValue); 151 | } else { 152 | paramMapping.push({ 153 | name: usedParam.name, 154 | type: ParameterTransform.Scalar, 155 | assignedIndex, 156 | required: usedParam.required, 157 | } as ScalarParameter); 158 | } 159 | 160 | usedParam.locs.forEach((loc) => 161 | intervals.push({ 162 | ...loc, 163 | sub: `$${assignedIndex}`, 164 | }), 165 | ); 166 | } 167 | const flatStr = replaceIntervals(queryIR.statement, intervals); 168 | return { 169 | mapping: paramMapping, 170 | query: flatStr, 171 | bindings, 172 | }; 173 | }; 174 | -------------------------------------------------------------------------------- /packages/runtime/src/preprocessor.ts: -------------------------------------------------------------------------------- 1 | export type Scalar = string | number | null; 2 | 3 | export enum ParameterTransform { 4 | Scalar, 5 | Spread, 6 | Pick, 7 | PickSpread, 8 | } 9 | 10 | export interface ScalarParameter { 11 | name: string; 12 | type: ParameterTransform.Scalar; 13 | required: boolean; 14 | assignedIndex: number; 15 | } 16 | 17 | export interface DictParameter { 18 | name: string; 19 | type: ParameterTransform.Pick; 20 | dict: { 21 | [key: string]: ScalarParameter; 22 | }; 23 | } 24 | 25 | export interface ScalarArrayParameter { 26 | name: string; 27 | type: ParameterTransform.Spread; 28 | required: boolean; 29 | assignedIndex: number | number[]; 30 | } 31 | 32 | export interface DictArrayParameter { 33 | name: string; 34 | type: ParameterTransform.PickSpread; 35 | dict: { 36 | [key: string]: ScalarParameter; 37 | }; 38 | } 39 | export type QueryParameter = 40 | | ScalarParameter 41 | | ScalarArrayParameter 42 | | DictParameter 43 | | DictArrayParameter; 44 | 45 | export interface InterpolatedQuery { 46 | query: string; 47 | mapping: QueryParameter[]; 48 | bindings: Scalar[]; 49 | } 50 | 51 | export interface NestedParameters { 52 | [subParamName: string]: Scalar; 53 | } 54 | 55 | export interface QueryParameters { 56 | [paramName: string]: 57 | | Scalar 58 | | NestedParameters 59 | | Scalar[] 60 | | NestedParameters[]; 61 | } 62 | 63 | export function replaceIntervals( 64 | str: string, 65 | intervals: { a: number; b: number; sub: string }[], 66 | ) { 67 | if (intervals.length === 0) { 68 | return str; 69 | } 70 | intervals.sort((x, y) => x.a - y.a); 71 | let offset = 0; 72 | let result = ''; 73 | for (const interval of intervals) { 74 | const a = str.slice(0, interval.a + offset); 75 | const c = str.slice(interval.b + offset + 1, str.length); 76 | result = a + interval.sub + c; 77 | offset += result.length - str.length; 78 | str = result; 79 | } 80 | return result; 81 | } 82 | -------------------------------------------------------------------------------- /packages/runtime/src/tag.ts: -------------------------------------------------------------------------------- 1 | import { SQLQueryIR, parseTSQuery, TSQueryAST } from '@pgtyped/parser'; 2 | import { processSQLQueryIR } from './preprocessor-sql.js'; 3 | import { processTSQueryAST } from './preprocessor-ts.js'; 4 | 5 | export interface ICursor { 6 | read(rowCount: number): Promise; 7 | close(): Promise; 8 | } 9 | 10 | export interface IDatabaseConnection { 11 | query: (query: string, bindings: any[]) => Promise<{ rows: any[] }>; 12 | stream?: (query: string, bindings: any[]) => ICursor; 13 | } 14 | 15 | /** Check for column modifier suffixes (exclamation and question marks). */ 16 | function isHintedColumn(columnName: string): boolean { 17 | const lastCharacter = columnName[columnName.length - 1]; 18 | return lastCharacter === '!' || lastCharacter === '?'; 19 | } 20 | 21 | function mapQueryResultRows(rows: any[]): any[] { 22 | for (const row of rows) { 23 | for (const columnName in row) { 24 | if (isHintedColumn(columnName)) { 25 | const newColumnNameWithoutSuffix = columnName.slice(0, -1); 26 | row[newColumnNameWithoutSuffix] = row[columnName] ?? undefined; 27 | delete row[columnName]; 28 | } 29 | if (row[columnName] === null) { 30 | row[columnName] = undefined; 31 | } 32 | } 33 | } 34 | return rows; 35 | } 36 | 37 | /* Used for SQL-in-TS */ 38 | export class TaggedQuery { 39 | public run: ( 40 | params: TTypePair['params'], 41 | dbConnection: IDatabaseConnection, 42 | ) => Promise>; 43 | 44 | public stream: ( 45 | params: TTypePair['params'], 46 | dbConnection: IDatabaseConnection, 47 | ) => ICursor>; 48 | 49 | private readonly query: TSQueryAST; 50 | 51 | constructor(query: TSQueryAST) { 52 | this.query = query; 53 | this.run = async (params, connection) => { 54 | const { query: processedQuery, bindings } = processTSQueryAST( 55 | this.query, 56 | params as any, 57 | ); 58 | const result = await connection.query(processedQuery, bindings); 59 | return mapQueryResultRows(result.rows); 60 | }; 61 | this.stream = (params, connection) => { 62 | const { query: processedQuery, bindings } = processTSQueryAST( 63 | this.query, 64 | params as any, 65 | ); 66 | if (connection.stream == null) 67 | throw new Error("Connection doesn't support streaming."); 68 | const cursor = connection.stream(processedQuery, bindings); 69 | return { 70 | async read(rowCount: number) { 71 | const rows = await cursor.read(rowCount); 72 | return mapQueryResultRows(rows); 73 | }, 74 | async close() { 75 | await cursor.close(); 76 | }, 77 | }; 78 | }; 79 | } 80 | } 81 | 82 | interface ITypePair { 83 | params: any; 84 | result: any; 85 | } 86 | 87 | export const sql = ( 88 | stringsArray: TemplateStringsArray, 89 | ) => { 90 | const { query } = parseTSQuery(stringsArray[0]); 91 | return new TaggedQuery(query); 92 | }; 93 | 94 | /* Used for pure SQL */ 95 | export class PreparedQuery { 96 | public run: ( 97 | params: TParamType, 98 | dbConnection: IDatabaseConnection, 99 | ) => Promise>; 100 | 101 | public stream: ( 102 | params: TParamType, 103 | dbConnection: IDatabaseConnection, 104 | ) => ICursor>; 105 | 106 | private readonly queryIR: SQLQueryIR; 107 | 108 | constructor(queryIR: SQLQueryIR) { 109 | this.queryIR = queryIR; 110 | this.run = async (params, connection) => { 111 | const { query: processedQuery, bindings } = processSQLQueryIR( 112 | this.queryIR, 113 | params as any, 114 | ); 115 | const result = await connection.query(processedQuery, bindings); 116 | return mapQueryResultRows(result.rows); 117 | }; 118 | this.stream = (params, connection) => { 119 | const { query: processedQuery, bindings } = processSQLQueryIR( 120 | this.queryIR, 121 | params as any, 122 | ); 123 | if (connection.stream == null) 124 | throw new Error("Connection doesn't support streaming."); 125 | const cursor = connection.stream(processedQuery, bindings); 126 | return { 127 | async read(rowCount: number) { 128 | const rows = await cursor.read(rowCount); 129 | return mapQueryResultRows(rows); 130 | }, 131 | async close() { 132 | await cursor.close(); 133 | }, 134 | }; 135 | }; 136 | } 137 | } 138 | 139 | export default sql; 140 | -------------------------------------------------------------------------------- /packages/runtime/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib/", 5 | "rootDir": "./src/", 6 | }, 7 | "exclude": ["lib", "**/*.test.ts", "jest.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/wire/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib -------------------------------------------------------------------------------- /packages/wire/README.md: -------------------------------------------------------------------------------- 1 | ### PgTyped Wire 2 | 3 | This package implements most of the Postgres protocol for pgTyped internal use. 4 | Refer to root [README](https://github.com/adelsz/pgtyped) for details. 5 | -------------------------------------------------------------------------------- /packages/wire/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | snapshotFormat: { 5 | escapeString: true, 6 | printBasicPrototype: true, 7 | }, 8 | roots: ['src'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | useESM: true, 17 | }, 18 | ], 19 | }, 20 | preset: 'ts-jest/presets/default-esm', 21 | testRegex: '\\.test\\.tsx?$', 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /packages/wire/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pgtyped/wire", 3 | "version": "2.2.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "import": "./lib/index.js", 8 | "types": "./lib/index.d.ts" 9 | } 10 | }, 11 | "main": "lib/index.js", 12 | "types": "lib/index.d.ts", 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/adelsz/pgtyped.git" 17 | }, 18 | "homepage": "https://github.com/adelsz/pgtyped", 19 | "publishConfig": { 20 | "access": "public" 21 | }, 22 | "files": [ 23 | "lib" 24 | ], 25 | "scripts": { 26 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", 27 | "build": "tsc", 28 | "check": "tsc --noEmit", 29 | "watch": "tsc --watch --preserveWatchOutput" 30 | }, 31 | "dependencies": { 32 | "debug": "^4.1.1" 33 | }, 34 | "devDependencies": { 35 | "@types/debug": "^4.1.7", 36 | "@types/node": "18.16.19" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/wire/src/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cString, 3 | cStringDict, 4 | dictToArray, 5 | fixedArray, 6 | int32, 7 | sumSize, 8 | notNullTerminatedString, 9 | } from '../src/helpers.js'; 10 | 11 | test('cString works', () => { 12 | const str = 'a'; 13 | const base = cString(str); 14 | const expected = Buffer.from([str.charCodeAt(0), 0]); 15 | expect(base).toEqual(expected); 16 | }); 17 | 18 | test('int32 works', () => { 19 | const base = int32(1000000); 20 | const expected = Buffer.from([0, 15, 66, 64]); 21 | expect(base).toEqual(expected); 22 | }); 23 | 24 | test('sumSize works', () => { 25 | const base = sumSize([ 26 | [1, 2], 27 | [3, 4], 28 | ]); 29 | expect(base).toBe(4); 30 | }); 31 | 32 | test('dictToArray works', () => { 33 | const base = dictToArray({ a: 'x', b: 'y' }); 34 | const expected = ['a', 'x', 'b', 'y']; 35 | expect(base).toEqual(expected); 36 | }); 37 | 38 | test('cStringDicts works', () => { 39 | const base = cStringDict({ a: 'x', b: 'y' }); 40 | const expected = Buffer.from([97, 0, 120, 0, 98, 0, 121, 0, 0]); 41 | expect(base).toEqual(expected); 42 | }); 43 | 44 | test('fixedArray works', () => { 45 | const base = fixedArray( 46 | ({ a, b }) => [int32(a), int32(b)], 47 | [ 48 | { a: 1, b: 2 }, 49 | { a: 3, b: 4 }, 50 | ], 51 | ); 52 | // prettier-ignore 53 | const expected = Buffer.from([ 54 | 0, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 55 | ]); 56 | expect(base).toEqual(expected); 57 | }); 58 | 59 | test('notNullTerminatedString works', () => { 60 | const base = notNullTerminatedString('test'); 61 | const expected = Buffer.from([116, 101, 115, 116]); 62 | expect(base).toEqual(expected); 63 | }); 64 | -------------------------------------------------------------------------------- /packages/wire/src/helpers.ts: -------------------------------------------------------------------------------- 1 | interface ISized { 2 | length: number; 3 | } 4 | export const sumSize = (array: ISized[]): number => 5 | array.reduce((acc, e) => acc + e.length, 0); 6 | 7 | export const dictToArray = (dict: { [key: string]: string }): string[] => 8 | Object.entries(dict).reduce( 9 | (acc, [key, val]) => [...acc, key, val], 10 | [] as string[], 11 | ); 12 | 13 | export const int16 = (val: number): Buffer => { 14 | const buf = Buffer.alloc(2); 15 | buf.writeUInt16BE(val, 0); 16 | return buf; 17 | }; 18 | 19 | export const int32 = (val: number): Buffer => { 20 | const buf = Buffer.alloc(4); 21 | buf.writeUInt32BE(val, 0); 22 | return buf; 23 | }; 24 | 25 | export const cByteDict = (dict: { [key: string]: string }): Buffer => 26 | null as any; 27 | 28 | export const cStringDict = (dict: { [key: string]: string }): Buffer => { 29 | const dictArray = dictToArray(dict); 30 | const count: number = sumSize(dictArray) + dictArray.length; 31 | 32 | // extra byte for dict terminator 33 | const buf = Buffer.alloc(count + 1, 0); 34 | 35 | let offset = 0; 36 | dictArray.forEach((str) => { 37 | offset = offset + buf.write(str, offset) + 1; 38 | }); 39 | return buf; 40 | }; 41 | 42 | export const cStringUnknownLengthArray = (array: string[]): Buffer => 43 | null as any; 44 | 45 | export const byte1 = (num: string): Buffer => Buffer.from(num); 46 | 47 | export const byte4 = (): Buffer => null as any; 48 | 49 | export const byteN = (buf: Buffer): Buffer => null as any; 50 | 51 | export const cString = (str: string): Buffer => { 52 | const buf = Buffer.concat([Buffer.from(str, 'utf8'), Buffer.from([0])]); 53 | return buf; 54 | }; 55 | 56 | export const notNullTerminatedString = (str: string): Buffer => { 57 | const buf = Buffer.alloc(str.length, 0); 58 | buf.write(str); 59 | return buf; 60 | }; 61 | 62 | export const fixedArray = ( 63 | builder: (item: Item) => Buffer[], 64 | items: Item[], 65 | ): Buffer => { 66 | const builtItems = items.map(builder); 67 | const size = builtItems.reduce( 68 | (acc, item) => acc + sumSize(item), 69 | 2, // Two extra bytes for the int16 item count indicator 70 | ); 71 | const result = Buffer.alloc(size, 0); 72 | result.writeUInt16BE(items.length, 0); 73 | let offset = 2; 74 | builtItems.forEach((bufferArray) => 75 | bufferArray.forEach((buffer) => { 76 | buffer.copy(result, offset); 77 | offset = offset + buffer.length; 78 | }), 79 | ); 80 | return result; 81 | }; 82 | -------------------------------------------------------------------------------- /packages/wire/src/index.ts: -------------------------------------------------------------------------------- 1 | export { AsyncQueue } from './queue.js'; 2 | 3 | export { messages, PreparedObjectType } from './messages.js'; 4 | 5 | export { cString } from './helpers.js'; 6 | -------------------------------------------------------------------------------- /packages/wire/src/queue.ts: -------------------------------------------------------------------------------- 1 | import * as net from 'net'; 2 | import * as tls from 'tls'; 3 | 4 | import { 5 | buildMessage, 6 | parseMessage, 7 | parseOneOf, 8 | ParseResult, 9 | } from './protocol.js'; 10 | 11 | import { IClientMessage, IServerMessage, messages } from './messages.js'; 12 | 13 | import debugBase from 'debug'; 14 | const debug = debugBase('pg-wire:socket'); 15 | 16 | type Box = T extends IServerMessage ? P : any; 17 | type Boxified = { [P in keyof T]: Box }; 18 | 19 | export class AsyncQueue { 20 | public bufferOffset: number = 0; 21 | public buffer: Buffer = Buffer.alloc(0); 22 | public socket: net.Socket; 23 | public replyPending: { 24 | resolve: (data: any) => any; 25 | reject: (data: any) => any; 26 | parser: (buf: Buffer, offset: number) => ParseResult; 27 | } | null = null; 28 | constructor() { 29 | this.socket = new net.Socket({}); 30 | } 31 | public connect(passedOptions: { 32 | port: number; 33 | host: string; 34 | ssl?: tls.ConnectionOptions | boolean; 35 | }): Promise { 36 | const { ssl, ...connectOptions } = passedOptions; 37 | const sslEnabled = ssl === true || ssl != null; 38 | 39 | const attachDataListener = () => { 40 | this.socket.on('data', (buffer: Buffer) => { 41 | debug('received %o bytes', buffer.length); 42 | this.buffer = Buffer.concat([this.buffer, buffer]); 43 | this.processQueue(); 44 | }); 45 | }; 46 | 47 | return new Promise((resolve) => { 48 | this.socket.on('connect', () => { 49 | debug('socket connected'); 50 | 51 | if (sslEnabled) { 52 | this.send(messages.sslRequest, {}); 53 | } else { 54 | attachDataListener(); 55 | resolve(); 56 | } 57 | }); 58 | 59 | if (sslEnabled) { 60 | this.socket.once('data', (buffer) => { 61 | const responseCode = buffer.toString('utf8'); 62 | switch (responseCode) { 63 | case 'S': 64 | break; 65 | case 'N': 66 | this.socket.end(); 67 | throw new Error('The server does not support SSL connections'); 68 | default: 69 | this.socket.end(); 70 | throw new Error( 71 | 'There was an error establishing an SSL connection', 72 | ); 73 | } 74 | 75 | const options: tls.ConnectionOptions = { 76 | socket: this.socket, 77 | }; 78 | 79 | if (ssl !== true) { 80 | Object.assign(options, ssl); 81 | } 82 | 83 | if (net.isIP(connectOptions.host) === 0) { 84 | options.servername = connectOptions.host; 85 | } 86 | 87 | try { 88 | this.socket = tls.connect(options); 89 | } catch (err) { 90 | debug('ssl error', err); 91 | 92 | this.socket.end(); 93 | throw new Error( 94 | 'There was an error establishing an SSL connection', 95 | ); 96 | } 97 | 98 | attachDataListener(); 99 | resolve(); 100 | }); 101 | } 102 | 103 | this.socket.connect(connectOptions); 104 | }); 105 | } 106 | 107 | public async send( 108 | message: IClientMessage, 109 | params: Params, 110 | ): Promise { 111 | const buf = buildMessage(message, params); 112 | return new Promise((resolve) => { 113 | this.socket.write(buf, () => resolve()); 114 | debug('sent %o message', message.name); 115 | }); 116 | } 117 | 118 | public processQueue() { 119 | if (!this.replyPending || this.buffer.length === 0) { 120 | return; 121 | } 122 | const parsed = this.replyPending.parser(this.buffer, this.bufferOffset); 123 | 124 | if (parsed.type === 'IncompleteMessageError') { 125 | debug('received incomplete message'); 126 | return; 127 | } 128 | 129 | // Move queue cursor in any case 130 | if (parsed.bufferOffset === this.buffer.length) { 131 | this.bufferOffset = 0; 132 | this.buffer = Buffer.alloc(0); 133 | } else { 134 | this.bufferOffset = parsed.bufferOffset; 135 | } 136 | 137 | if (parsed.type === 'ServerError') { 138 | this.replyPending.reject(parsed); 139 | } else if (parsed.type === 'MessagePayload') { 140 | debug('resolved awaited %o message', parsed.messageName); 141 | this.replyPending.resolve(parsed.data); 142 | } else { 143 | debug('received ignored message'); 144 | this.processQueue(); 145 | } 146 | } 147 | /** 148 | * Waits for the next message to arrive and parses it, resolving with the parsed value. 149 | * @param serverMessages The message type to parse or an array of messages to match any of them 150 | * @returns The parsed params 151 | */ 152 | public async reply>>( 153 | ...serverMessages: Messages 154 | ): Promise[number]> { 155 | let parser: (buf: Buffer, offset: number) => ParseResult; 156 | if (serverMessages instanceof Array) { 157 | parser = (buf: Buffer, offset: number) => 158 | parseOneOf(serverMessages, buf, offset); 159 | } else { 160 | parser = (buf: Buffer, offset: number) => 161 | parseMessage(serverMessages, buf, offset); 162 | } 163 | return new Promise((resolve, reject) => { 164 | this.replyPending = { 165 | resolve, 166 | reject, 167 | parser, 168 | }; 169 | this.processQueue(); 170 | }); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /packages/wire/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib/", 5 | "rootDir": "./src/", 6 | }, 7 | "exclude": ["lib", "**/*.test.ts", "jest.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "matchPackagePatterns": ["*"], 6 | "schedule": ["every weekend"], 7 | "automerge": true, 8 | "automergeType": "branch", 9 | "minor": { 10 | "groupName": "all non-major dependencies", 11 | "groupSlug": "all-minor-patch" 12 | } 13 | }, 14 | { 15 | "matchPackageNames": ["antlr4ts", "antlr4ts-cli", "typescript"], 16 | "enabled": false 17 | }, 18 | { 19 | "matchPackagePrefixes": ["@docusaurus"], 20 | "enabled": false 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES6", 5 | "module": "ES2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "sourceMap": true /* Generates corresponding '.map' file. */, 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | // "composite": true, /* Enable project compilation */ 15 | // "incremental": true, /* Enable incremental compilation */ 16 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 26 | "strictNullChecks": true /* Enable strict null checks. */, 27 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 28 | "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */, 29 | "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-plugin-prettier", 6 | "tslint-config-prettier" 7 | ], 8 | "jsRules": {}, 9 | "rules": { 10 | "prettier": true, 11 | "object-literal-sort-keys": false, 12 | "ordered-imports": false, 13 | "arrow-parens": false, 14 | "array-type": false, 15 | "max-line-length": false, 16 | "max-classes-per-file": false, 17 | "no-unused-variable": true, 18 | "forin": false, 19 | "no-conditional-assignment": false 20 | }, 21 | "rulesDirectory": [], 22 | "linterOptions": { 23 | "exclude": [ 24 | "**/*types.ts", 25 | "**/*Lexer.ts", 26 | "**/*Parser.ts", 27 | "**/*ParserListener.ts", 28 | "**/*ParserVisitor.ts", 29 | "**/*queries.ts", 30 | "packages/example/**/*.ts", 31 | "packages/query/src/loader/*/{index,query}.ts", 32 | "packages/query/src/loader/*/parser/**/*.ts" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | --------------------------------------------------------------------------------