├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── examples ├── better-sqlite3.ts ├── mysql2.ts ├── pg.ts └── sqlite3.ts ├── package-lock.json ├── package.json ├── packages ├── qustar-better-sqlite3 │ ├── .gitkeep │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── better-sqlite3-connector.ts │ │ ├── index.ts │ │ ├── load-better-sqlite3.ts │ │ └── utils.ts │ ├── tests │ │ └── suite.test.ts │ ├── tsconfig.cjs.json │ └── tsconfig.json ├── qustar-mysql2 │ ├── .gitkeep │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── mysql2.ts │ │ └── utils.ts │ ├── tests │ │ └── suite.test.ts │ ├── tsconfig.cjs.json │ └── tsconfig.json ├── qustar-pg │ ├── .gitkeep │ ├── .npmignore │ ├── README.md │ ├── oid.txt │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── load-pg.ts │ │ ├── pg-connector.ts │ │ └── utils.ts │ ├── tests │ │ └── suite.test.ts │ ├── tsconfig.cjs.json │ └── tsconfig.json ├── qustar-sqllite3 │ ├── .gitkeep │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── load-sqlite3.ts │ │ ├── sqlite3-connector.ts │ │ └── utils.ts │ ├── tests │ │ └── suite.test.ts │ ├── tsconfig.cjs.json │ └── tsconfig.json ├── qustar-testsuite │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── db.ts │ │ ├── describe.ts │ │ ├── expect.ts │ │ ├── index.ts │ │ ├── integration │ │ │ ├── combination.ts │ │ │ ├── expr.ts │ │ │ ├── filter.ts │ │ │ ├── flat-map.ts │ │ │ ├── group-by.ts │ │ │ ├── join.ts │ │ │ ├── map.ts │ │ │ ├── order.ts │ │ │ ├── pagination.ts │ │ │ ├── sql.ts │ │ │ ├── terminator.ts │ │ │ └── unique.ts │ │ └── utils.ts │ ├── tests │ │ └── utils.test.ts │ ├── todo.yaml │ ├── tsconfig.cjs.json │ ├── tsconfig.json │ └── vitest.config.ts └── qustar │ ├── package.json │ ├── src │ ├── connector.ts │ ├── descriptor.ts │ ├── index.ts │ ├── literal.ts │ ├── query │ │ ├── compiler.ts │ │ ├── expr.ts │ │ ├── projection.ts │ │ ├── query.ts │ │ ├── schema.ts │ │ └── sql.ts │ ├── qustar.ts │ ├── render │ │ ├── mysql.ts │ │ ├── postgresql.ts │ │ ├── sql.ts │ │ └── sqlite.ts │ ├── sql │ │ ├── mapper.ts │ │ ├── optimizer.ts │ │ └── sql.ts │ ├── types │ │ └── query.ts │ └── utils.ts │ ├── tests │ ├── snapshot.test.ts │ ├── types │ │ └── schema.test-d.ts │ └── utils.test.ts │ ├── tsconfig.cjs.json │ └── tsconfig.json ├── scripts ├── build.ts ├── common │ ├── example-schema.ts │ └── utils.ts ├── deploy.ts └── playground.ts ├── todo.yaml ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true 9 | max_line_length = 80 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | packages/*/dist 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/", 3 | "rules": { 4 | "n/no-extraneous-import": "warn", 5 | "n/no-unpublished-import": "warn", 6 | "@typescript-eslint/no-empty-interface": "off", 7 | "@typescript-eslint/no-explicit-any": "off", 8 | "@typescript-eslint/no-namespace": "off", 9 | "@typescript-eslint/no-unused-vars": [ 10 | "warn", 11 | { 12 | "argsIgnorePattern": "^_", 13 | "varsIgnorePattern": "^_", 14 | "caughtErrorsIgnorePattern": "^_" 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | # push: 3 | # branches: [main] 4 | workflow_dispatch: 5 | 6 | name: qustar 7 | 8 | jobs: 9 | tests: 10 | name: Run tests 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: ['18.x', '20.x', '22.x'] 15 | 16 | steps: 17 | - uses: AutoModality/action-clean@v1 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - run: npm run bootstrap 27 | - run: npm run test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # IDE 9 | .vscode 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Optional npm cache directory 40 | .npm 41 | 42 | # Optional eslint cache 43 | .eslintcache 44 | 45 | # Optional REPL history 46 | .node_repl_history 47 | 48 | # Output of 'npm pack' 49 | *.tgz 50 | 51 | # dotenv environment variable files 52 | .env* 53 | 54 | # Mac files 55 | .DS_Store 56 | 57 | # Yarn 58 | yarn-error.log 59 | .pnp/ 60 | .pnp.js 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # build 65 | /packages/*/dist 66 | 67 | # debug 68 | /debug 69 | /packages/*/debug 70 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('gts/.prettierrc.json'), 3 | plugins: ['prettier-plugin-organize-imports'], 4 | }; 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dmitry Tilyupo 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 | # Qustar 2 | 3 | [![npm version](https://img.shields.io/npm/v/qustar.svg)](https://www.npmjs.com/package/qustar) 4 | [![MIT license](https://img.shields.io/badge/license-MIT-blue)](https://github.com/tilyupo/qustar/blob/main/LICENSE) 5 | 6 | Query SQL database through an array-like API. 7 | 8 | ## Features 9 | 10 | ✅ Expressive AND high-level query builder 11 | ✅ TypeScript support 12 | ✅ SQL databases: 13 |   ✅ PostgreSQL 14 |   ✅ SQLite 15 |   ✅ MySQL 16 |   ✅ MariaDB 17 |   ⬜ SQL Server 18 |   ⬜ Oracle 19 | ✅ Navigation properties 20 | ✅ Codegen free 21 | ✅ Surprise free, all queries produce 100% SQL 22 | ✅ Raw SQL 23 | ⬜ Migrations 24 | ⬜ Transactions 25 | 26 | ## Quick start 27 | 28 | To start using qustar with PostgreSQL (the list of all supported data sources is [available below](#supported-database-drivers)) run the following command: 29 | 30 | ```sh 31 | npm install qustar qustar-pg pg 32 | ``` 33 | 34 | Here an example usage of qustar: 35 | 36 | ```ts 37 | import {PgConnector} from 'qustar-pg'; 38 | import {Q} from 'qustar'; 39 | 40 | // specify a schema 41 | const users = Q.table({ 42 | name: 'users', 43 | schema: { 44 | // generated is not required during insert 45 | id: Q.i32().generated(), // 32 bit integer 46 | firstName: Q.string(), // any text 47 | lastName: Q.string(), 48 | age: Q.i32().null(), // nullable integer 49 | }, 50 | }); 51 | 52 | // compose a query 53 | const query = users 54 | .orderByDesc(user => user.createdAt) 55 | // map will be translated into 100% SQL, as every other operation 56 | .map(user => ({ 57 | name: user.firstName.concat(' ', user.lastName), 58 | age: user.age, 59 | })) 60 | .limit(3); 61 | 62 | // connect to your database 63 | const connector = new PgConnector('postgresql://qustar:passwd@localhost:5432'); 64 | 65 | // run the query 66 | console.log('users:', await query.fetch(connector)); 67 | ``` 68 | 69 | Output: 70 | 71 | ```js 72 | { age: 54, name: 'Linus Torvalds' } 73 | { age: 29, name: 'Clark Kent' } 74 | { age: 18, name: 'John Smith' } 75 | ``` 76 | 77 | The query above will be translated to: 78 | 79 | ```sql 80 | SELECT 81 | "s1"."age", 82 | concat("s1"."firstName", ' ', "s1"."lastName") AS "name" 83 | FROM 84 | users AS "s1" 85 | ORDER BY 86 | ("s1"."createdAt") DESC 87 | LIMIT 88 | 3 89 | ``` 90 | 91 | Insert/update/delete: 92 | 93 | ```ts 94 | // insert 95 | await users.insert({firstName: 'New', lastName: 'User'}).execute(connector); 96 | 97 | // update 98 | await users 99 | .filter(user => user.id.eq(42)) 100 | .update(user => ({age: user.age.add(1)})) 101 | .execute(connector); 102 | 103 | // delete 104 | await users.delete(user => user.id.eq(42)).execute(connector); 105 | ``` 106 | 107 | ## Supported database drivers 108 | 109 | To execute query against a database you need a _connector_. There are many ready to use connectors that wrap existing NodeJS drivers: 110 | 111 | - PostgreSQL 112 | - [qustar-pg](https://www.npmjs.com/package/qustar-pg) 113 | - SQLite 114 | - [qustar-better-sqlite3](https://www.npmjs.com/package/qustar-better-sqlite3) (recommended) 115 | - [qustar-sqlite3](https://www.npmjs.com/package/qustar-sqlite3) 116 | - MySQL 117 | - [qustar-mysql2](https://www.npmjs.com/package/qustar-mysql2) 118 | - MariaDB 119 | - [qustar-mysql2](https://www.npmjs.com/package/qustar-mysql2) 120 | 121 | If you implemented your own connector, let me know and I will add it to the list above! 122 | 123 | [//]: # 'todo: add a link to a guide for creating a custom connector' 124 | 125 | ## Usage 126 | 127 | Any query starts from a table or a [raw sql](#raw-sql). We will talk more about raw queries later, for now the basic usage looks like this: 128 | 129 | ```ts 130 | import {Q} from 'qustar'; 131 | 132 | const users = Q.table({ 133 | name: 'users', 134 | schema: { 135 | id: Q.i32(), 136 | age: Q.i32().null(), 137 | // ... 138 | }, 139 | }); 140 | ``` 141 | 142 | In qustar you compose a query by calling query methods like `.filter` or `.map`: 143 | 144 | ```ts 145 | const young = users.filter(user => user.age.lt(18)); 146 | const youngIds = young.map(user => user.id); 147 | 148 | // or 149 | 150 | const ids = users.filter(user => user.age.lt(18)).map(user => user.id); 151 | ``` 152 | 153 | Queries are immutable, so you can reuse them safely. 154 | 155 | For methods like `.filter` or `.map` you pass a callback which returns an _expression_. Expression represents a condition or operation you wish to do. Expressions are build using methods like `.add` or `.eq`: 156 | 157 | ```ts 158 | // for arrays you would write: users.filter(x => x.age + 1 === x.height - 5) 159 | const a = users.filter(user => user.age.add(1).eq(user.height.sub(5))); 160 | 161 | // you can also use Q.eq to achieve the same 162 | import {Q} from 'qustar'; 163 | 164 | const b = users.map(user => Q.eq(user.age.add(1), user.height.sub(5)); 165 | ``` 166 | 167 | We can't use native operators like `+` or `===` because JavaScript doesn't support operator overloading. You can find full list of supported expression operations [here](#expressions). 168 | 169 | Now lets talk about queries and expressions. 170 | 171 | ### Query 172 | 173 | #### .filter(condition) 174 | 175 | ```ts 176 | const adults = users 177 | // users with age >= 18 178 | .filter(user => /* any expression */ user.age.gte(18)); 179 | ``` 180 | 181 | #### .map(mapper) 182 | 183 | ```ts 184 | const userIds = users.map(user => user.id); 185 | 186 | const user = users 187 | // you can map to an object 188 | .map(user => ({id: user.id, name: user.name})); 189 | 190 | const userInfo = users 191 | // you can map to nested objects 192 | .map(user => ({ 193 | id: user.id, 194 | info: { 195 | adult: user.age.gte(18), 196 | nameLength: user.name.length(), 197 | }, 198 | })); 199 | ``` 200 | 201 | #### .orderByDesc(selector), .orderByAsc(selector) 202 | 203 | ```ts 204 | const users = users 205 | // order by age in ascending order 206 | .orderByAsc(user => user.age) 207 | // then order by name in descending order 208 | .thenByDesc(user => user.name); 209 | ``` 210 | 211 | #### .drop(count), Query.limit(count) 212 | 213 | ```ts 214 | const users = users 215 | .orderByAsc(user => user.id) 216 | // skip first ten users 217 | .drop(10) 218 | // then take only five 219 | .limit(5); 220 | ``` 221 | 222 | #### .slice(start, end) 223 | 224 | You can also use `.slice` method to achieve the same: 225 | 226 | ```ts 227 | const users = users 228 | // start = 10, end = 15 229 | .slice(10, 15); 230 | ``` 231 | 232 | #### .{inner,left,right}Join(options) 233 | 234 | Qustar supports `.innerJoin`, `.leftJoin`, `.rightJoin` and `.fullJoin`: 235 | 236 | ```ts 237 | const bobPosts = posts 238 | .innerJoin({ 239 | right: users, 240 | condition: (post, user) => post.authorId.eq(user.id), 241 | select: (post, author) => ({ 242 | text: post.text, 243 | author: author.name, 244 | }), 245 | }) 246 | .filter(({author}) => author.like('bob%')); 247 | ``` 248 | 249 | #### .unique() 250 | 251 | You can select distinct rows using `.unique` method: 252 | 253 | ```ts 254 | const names = users.map(user => user.name).unique(); 255 | ``` 256 | 257 | #### .groupBy(options) 258 | 259 | ```ts 260 | const stats = users.groupBy({ 261 | by: user => user.age, 262 | select: user => ({ 263 | age: user.age, 264 | count: Expr.count(1), 265 | averageTax: user.salary.mul(user.taxRate).mean(), 266 | }), 267 | }); 268 | ``` 269 | 270 | #### .union(query) 271 | 272 | ```ts 273 | const studentNames = students.map(student => student.name); 274 | const teacherNames = teachers.map(teacher => teacher.name); 275 | 276 | const uniqueNames = studentNames.union(teacherNames); 277 | ``` 278 | 279 | #### .unionAll(query) 280 | 281 | ```ts 282 | const studentNames = students.map(student => student.name); 283 | const teacherNames = teachers.map(teacher => teacher.name); 284 | 285 | const peopleCount = studentNames.unionAll(teacherNames).count(); 286 | ``` 287 | 288 | #### .concat(query) 289 | 290 | ```ts 291 | const studentNames = students.map(student => student.name); 292 | const teacherNames = teachers.map(teacher => teacher.name); 293 | 294 | // concat preserves original ordering 295 | const allNames = studentNames.concat(teacherNames); 296 | ``` 297 | 298 | #### .intersect(query) 299 | 300 | ```ts 301 | const studentNames = students.map(student => student.name); 302 | const teacherNames = teachers.map(teacher => teacher.name); 303 | 304 | const studentAndTeacherNames = studentNames.intersect(teacherNames); 305 | ``` 306 | 307 | #### .except(query) 308 | 309 | ```ts 310 | const studentNames = students.map(student => student.name); 311 | const teacherNames = teachers.map(teacher => teacher.name); 312 | 313 | const studentOnlyNames = studentNames.except(teacherNames); 314 | ``` 315 | 316 | #### .flatMap(mapper) 317 | 318 | ```ts 319 | const postsWithAuthor = users.flatMap(user => 320 | posts 321 | .filter(post => post.authorId.eq(user.id)) 322 | .map(post => ({text: post.text, author: user.name})) 323 | ); 324 | ``` 325 | 326 | #### .includes(value) 327 | 328 | ```ts 329 | const userExists = users.map(user => user.id).includes(42); 330 | ``` 331 | 332 | #### Schema 333 | 334 | The list of supported column types: 335 | 336 | - **boolean**: true or false 337 | - **i8**: 8 bit integer 338 | - **i16**: 16 bit integer 339 | - **i32**: 32 bit integer 340 | - **i64**: 64 bit integer 341 | - **f32**: 32 bit floating point number 342 | - **f64**: 64 bit floating point number 343 | - **string**: variable length string 344 | 345 | [//]: '#' 'todo: add ref/back_ref docs' 346 | 347 | #### Raw sql 348 | 349 | You can use raw SQL like so: 350 | 351 | ```ts 352 | import {Q, sql} from 'qustar'; 353 | 354 | const users = Q.rawQuery({ 355 | sql: sql`SELECT * from users`, 356 | // we must specify schema so qustar knows how to compose a query 357 | schema: { 358 | id: Q.i32(), 359 | age: Q.i32().null(), 360 | }, 361 | }) 362 | .filter(user => user.age.lte(25)) 363 | .map(user => user.id); 364 | ``` 365 | 366 | You can also use aliases in a nested query like so: 367 | 368 | ```ts 369 | const postIds = users.flatMap(user => 370 | Q.rawQuery({ 371 | sql: sql` 372 | SELECT 373 | id 374 | FROM 375 | posts p 376 | WHERE p.authorId = ${user.id}' 377 | })`, 378 | schema: { 379 | id: Q.i32(), 380 | }, 381 | }); 382 | ); 383 | ``` 384 | 385 | You can use `Q.rawExpr` for raw SQL in a part of an operation: 386 | 387 | ```ts 388 | const halfIds = users.map(user => ({ 389 | halfId: Q.rawExpr({sql: sql`CAST(${user.id} as REAL) / 2`, schema: Q.f32()}), 390 | name: user.name, 391 | })); 392 | ``` 393 | 394 | The query above will be translated to: 395 | 396 | ```sql 397 | SELECT 398 | "s1"."name", 399 | (CAST(("s1"."id") as REAL) / 2) AS "halfId" 400 | FROM 401 | users AS "s1" 402 | ``` 403 | 404 | ## License 405 | 406 | MIT License, see `LICENSE`. 407 | -------------------------------------------------------------------------------- /examples/better-sqlite3.ts: -------------------------------------------------------------------------------- 1 | import {Q} from 'qustar'; 2 | import {BetterSqlite3Connector} from 'qustar-better-sqlite3'; 3 | 4 | // create a connector for in-memory SQLite database 5 | const connector = new BetterSqlite3Connector(':memory:'); 6 | 7 | // construct a query 8 | const query = Q.table({ 9 | name: 'users', 10 | schema: { 11 | id: Q.i32(), 12 | }, 13 | }); 14 | 15 | // run the query using the connector 16 | const users = await query.fetch(connector); 17 | 18 | // use the result 19 | console.log(users); 20 | 21 | // execute a statement 22 | await connector.execute('INSERT INTO users VALUES (42);'); 23 | 24 | // run a query 25 | await connector.query('SELECT 42 as meaning'); 26 | 27 | // run a parametrized query 28 | await connector.query({ 29 | sql: 'SELECT id FROM users WHERE id = ?', 30 | args: [42], 31 | }); 32 | 33 | // close the connector 34 | await connector.close(); 35 | -------------------------------------------------------------------------------- /examples/mysql2.ts: -------------------------------------------------------------------------------- 1 | import {Q} from 'qustar'; 2 | import {Mysql2Connector} from 'qustar-mysql2'; 3 | 4 | // create a connector for MySQL database 5 | const connector = new Mysql2Connector( 6 | 'mysql://user:password@localhost:3306/qustar' 7 | ); 8 | 9 | // construct a query 10 | const query = Q.table({ 11 | name: 'users', 12 | schema: { 13 | id: Q.i32(), 14 | }, 15 | }); 16 | 17 | // run the query using the connector 18 | const users = await query.fetch(connector); 19 | 20 | // use the result 21 | console.log(users); 22 | 23 | // execute a statement 24 | await connector.execute('INSERT INTO users VALUES (42);'); 25 | 26 | // run a query 27 | await connector.query('SELECT 42 as meaning'); 28 | 29 | // run a parametrized query 30 | await connector.query({ 31 | sql: 'SELECT id FROM users WHERE id = ?', 32 | args: [42], 33 | }); 34 | 35 | // close the connector 36 | await connector.close(); 37 | -------------------------------------------------------------------------------- /examples/pg.ts: -------------------------------------------------------------------------------- 1 | import {Q} from 'qustar'; 2 | import {PgConnector} from 'qustar-pg'; 3 | 4 | // create a connector for PostgreSQL database 5 | const connector = new PgConnector( 6 | 'postgresql://user:password@localhost:5432/qustar' 7 | ); 8 | 9 | // construct a query 10 | const query = Q.table({ 11 | name: 'users', 12 | schema: { 13 | id: Q.i32(), 14 | }, 15 | }); 16 | 17 | // run the query using the connector 18 | const users = await query.fetch(connector); 19 | 20 | // use the result 21 | console.log(users); 22 | 23 | // execute a statement 24 | await connector.execute('INSERT INTO users VALUES (42);'); 25 | 26 | // run a query 27 | await connector.query('SELECT 42 as meaning'); 28 | 29 | // run a parametrized query 30 | await connector.query({ 31 | sql: 'SELECT id FROM users WHERE id = ?', 32 | args: [42], 33 | }); 34 | 35 | // close the connector 36 | await connector.close(); 37 | -------------------------------------------------------------------------------- /examples/sqlite3.ts: -------------------------------------------------------------------------------- 1 | import {Q} from 'qustar'; 2 | import {Sqlite3Connector} from 'qustar-sqlite3'; 3 | 4 | // create a connector for in-memory SQLite database 5 | const connector = new Sqlite3Connector(':memory:'); 6 | 7 | // construct a query 8 | const query = Q.table({ 9 | name: 'users', 10 | schema: { 11 | id: Q.i32(), 12 | }, 13 | }); 14 | 15 | // run the query using the connector 16 | const users = await query.fetch(connector); 17 | 18 | // use the result 19 | console.log(users); 20 | 21 | // execute a statement 22 | await connector.execute('INSERT INTO users VALUES (42);'); 23 | 24 | // run a query 25 | await connector.query('SELECT 42 as meaning'); 26 | 27 | // run a parametrized query 28 | await connector.query({ 29 | sql: 'SELECT id FROM users WHERE id = ?', 30 | args: [42], 31 | }); 32 | 33 | // close the connector 34 | await connector.close(); 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qustar-monorepo", 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "type": "module", 7 | "engines": { 8 | "node": ">=14.0.0" 9 | }, 10 | "scripts": { 11 | "start": "tsx ./scripts/playground.ts", 12 | "bootstrap": "npm install && npm install --workspaces && npm run build", 13 | "deploy": "npm run deploy --workspaces", 14 | "test": "npm test --workspaces", 15 | "build": "npm run build --workspaces", 16 | "dev": "run-p dev:*", 17 | "dev:qustar": "npm run dev --workspace=qustar", 18 | "dev:qustar-testsuite": "npm run dev --workspace=qustar-testsuite", 19 | "dev:qustar-pg": "npm run dev --workspace=qustar-pg", 20 | "dev:qustar-sqlite3": "npm run dev --workspace=qustar-sqlite3", 21 | "dev:qustar-better-sqlite3": "npm run dev --workspace=qustar-better-sqlite3", 22 | "dev:qustar-mysql2": "npm run dev --workspace=qustar-mysql2" 23 | }, 24 | "devDependencies": { 25 | "concurrently": "^8.2.2", 26 | "gts": "^5.2.0", 27 | "npm-run-all": "^4.1.5", 28 | "pg": "^8.12.0", 29 | "prettier": "^3.3.2", 30 | "prettier-plugin-organize-imports": "^3.2.3", 31 | "rimraf": "^6.0.1", 32 | "tsx": "^4.15.6", 33 | "typescript": "~5.1.0", 34 | "vitest": "^2.0.5" 35 | }, 36 | "dependencies": { 37 | "axios": "^1.7.2", 38 | "semver": "^7.6.3" 39 | } 40 | } -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-better-sqlite3/.gitkeep -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-better-sqlite3/.npmignore -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/README.md: -------------------------------------------------------------------------------- 1 | # qustar-better-sqlite3 2 | 3 | SQLite support for [qustar](https://www.npmjs.com/package/qustar) via [better-sqlite3](https://www.npmjs.com/package/better-sqlite3) package. 4 | 5 | ## Installation 6 | 7 | To start using `better-sqlite3` with `qustar` you need to install the following packages: 8 | 9 | ``` 10 | npm install qustar qustar-better-sqlite3 better-sqlite3 11 | ``` 12 | 13 | ## Usage 14 | 15 | Here is a minimal example: 16 | 17 | ```ts 18 | import {Q} from 'qustar'; 19 | import {BetterSqlite3Connector} from 'qustar-better-sqlite3'; 20 | 21 | // create a connector for in-memory SQLite database 22 | const connector = new BetterSqlite3Connector(':memory:'); 23 | 24 | // construct a query 25 | const query = Q.table({ 26 | name: 'users', 27 | schema: { 28 | id: Q.i32(), 29 | }, 30 | }); 31 | 32 | // run the query using the connector 33 | const users = await query.fetch(connector); 34 | 35 | // use the result 36 | console.log(users); 37 | 38 | // close the connector 39 | await connector.close(); 40 | ``` 41 | 42 | You can also create `BetterSqlite3Connector` by passing an instance of `better-sqlite3` database: 43 | 44 | ```ts 45 | import Database from 'better-sqlite3'; 46 | 47 | const db = new Database('/path/to/db.sqlite', { 48 | readonly: true, 49 | fileMustExist: false, 50 | }); 51 | 52 | const connector = new BetterSqlite3Connector(db); 53 | ``` 54 | 55 | But usually it's more convenient to pass database options directly to the connector: 56 | 57 | ```ts 58 | import {BetterSqlite3Connector} from 'qustar-better-sqlite3'; 59 | 60 | // connector will pass the options to better-sqlite3 61 | const connector = new BetterSqlite3Connector('/path/to/db.sqlite', { 62 | readonly: true, 63 | fileMustExist: false, 64 | }); 65 | ``` 66 | 67 | You can run raw SQL using a connector: 68 | 69 | ```ts 70 | // execute a statement 71 | await connector.execute('INSERT INTO users VALUES (42);'); 72 | 73 | // run a query 74 | await connector.query('SELECT 42 as meaning'); 75 | 76 | // run a parametrized query 77 | await connector.query({ 78 | sql: 'SELECT id FROM users WHERE id = ?', 79 | args: [42], 80 | }); 81 | ``` 82 | 83 | ## License 84 | 85 | MIT License, see `LICENSE`. 86 | -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qustar-better-sqlite3", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "engines": { 6 | "node": ">=10.4.0" 7 | }, 8 | "description": "better-sqlite3 data source for qustar", 9 | "keywords": [ 10 | "qustar", 11 | "sqlite", 12 | "better-sqlite3", 13 | "sql", 14 | "typescript" 15 | ], 16 | "main": "dist/cjs/src/index.js", 17 | "module": "dist/esm/src/index.js", 18 | "types": "dist/esm/src/index.d.ts", 19 | "files": [ 20 | "dist/esm/src", 21 | "dist/esm/package.json", 22 | "dist/cjs/src", 23 | "dist/cjs/package.json", 24 | "src" 25 | ], 26 | "exports": { 27 | ".": { 28 | "import": "./dist/esm/src/index.js", 29 | "require": "./dist/cjs/src/index.js", 30 | "default": "./dist/cjs/src/index.js" 31 | } 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/tilyupo/qustar.git", 36 | "directory": "packages/qustar-better-sqlite3" 37 | }, 38 | "type": "module", 39 | "scripts": { 40 | "clean": "rimraf dist", 41 | "build": "tsx ../../scripts/build.ts", 42 | "dev": "tsc -w", 43 | "deploy": "tsx ../../scripts/deploy.ts", 44 | "test": "vitest run" 45 | }, 46 | "peerDependencies": { 47 | "better-sqlite3": "*", 48 | "qustar": "*" 49 | }, 50 | "devDependencies": { 51 | "@types/better-sqlite3": "^7.6.7", 52 | "qustar-testsuite": "^0.0.1", 53 | "rimraf": "^6.0.1", 54 | "tsx": "^4.17.0", 55 | "vitest": "^1.6.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/src/better-sqlite3-connector.ts: -------------------------------------------------------------------------------- 1 | import {Database, Options} from 'better-sqlite3'; 2 | import {Connector, QuerySql, SqlCommand, renderSqlite} from 'qustar'; 3 | import {loadBetterSqlite3} from './load-better-sqlite3.js'; 4 | 5 | export class BetterSqlite3Connector implements Connector { 6 | private readonly db: Promise; 7 | 8 | constructor(filename: string, options?: Options); 9 | constructor(db: Database); 10 | constructor(dbOrFilename: any, options?: Options) { 11 | if (typeof dbOrFilename === 'string') { 12 | this.db = loadBetterSqlite3().then(x => x(dbOrFilename, options)); 13 | } else { 14 | this.db = dbOrFilename; 15 | } 16 | } 17 | 18 | render(query: QuerySql): SqlCommand { 19 | return renderSqlite(query); 20 | } 21 | 22 | async execute(sql: string): Promise { 23 | (await this.db).exec(sql); 24 | 25 | return Promise.resolve(); 26 | } 27 | 28 | async query(command: SqlCommand | string): Promise { 29 | const {sql, args} = SqlCommand.derive(command); 30 | const preparedQuery = (await this.db).prepare(sql); 31 | const result = preparedQuery 32 | .all( 33 | ...args.map(arg => { 34 | // better-sqlite3 doesn't support booleans 35 | if (arg === true) { 36 | return 1; 37 | } else if (arg === false) { 38 | return 0; 39 | } else if (typeof arg === 'number' && Number.isSafeInteger(arg)) { 40 | return BigInt(arg); 41 | } else { 42 | return arg; 43 | } 44 | }) 45 | ) 46 | .map((x: any) => { 47 | const result: any = {}; 48 | for (const key of Object.keys(x)) { 49 | // SQLite uses : for duplicates 50 | if (key.indexOf(':') !== -1) continue; 51 | result[key] = x[key]; 52 | } 53 | return result; 54 | }); 55 | 56 | return Promise.resolve(result); 57 | } 58 | 59 | async close(): Promise { 60 | (await this.db).close(); 61 | return Promise.resolve(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/src/index.ts: -------------------------------------------------------------------------------- 1 | export {BetterSqlite3Connector} from './better-sqlite3-connector.js'; 2 | -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/src/load-better-sqlite3.ts: -------------------------------------------------------------------------------- 1 | async function loadBetterSqlite3(): Promise { 2 | if (typeof require === 'function') { 3 | return require('better-sqlite3'); 4 | } else { 5 | return (await import('better-sqlite3')).default; 6 | } 7 | } 8 | 9 | export {loadBetterSqlite3}; 10 | -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function indent(s: string, depth = 1): string { 2 | return s 3 | .split('\n') 4 | .map(x => ' '.repeat(depth) + x) 5 | .join('\n'); 6 | } 7 | -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/tests/suite.test.ts: -------------------------------------------------------------------------------- 1 | import * as Database from 'better-sqlite3'; 2 | import {createInitSqlScript, describeConnector} from 'qustar-testsuite'; 3 | import {afterEach, beforeEach, describe, expect, test} from 'vitest'; 4 | import {BetterSqlite3Connector} from '../src/better-sqlite3-connector.js'; 5 | 6 | describeConnector( 7 | { 8 | test, 9 | describe, 10 | expectDeepEqual: (a, b, m) => expect(a).to.deep.equal(b, m), 11 | beforeEach, 12 | afterEach, 13 | }, 14 | new BetterSqlite3Connector(new Database(':memory:')), 15 | createInitSqlScript('sqlite'), 16 | {fuzzing: false, lateralSupport: false} 17 | ); 18 | -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/cjs", 5 | "module": "CommonJS", 6 | }, 7 | } -------------------------------------------------------------------------------- /packages/qustar-better-sqlite3/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./dist/esm" 6 | }, 7 | "include": ["src/**/*.ts", "tests/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/qustar-mysql2/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-mysql2/.gitkeep -------------------------------------------------------------------------------- /packages/qustar-mysql2/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-mysql2/.npmignore -------------------------------------------------------------------------------- /packages/qustar-mysql2/README.md: -------------------------------------------------------------------------------- 1 | # qustar-mysql2 2 | 3 | MySQL support for [qustar](https://www.npmjs.com/package/qustar) via [mysql2](https://www.npmjs.com/package/mysql2) package. 4 | 5 | ## Installation 6 | 7 | To start using `mysql2` with `qustar` you need to install the following packages: 8 | 9 | ``` 10 | npm install qustar qustar-mysql2 mysql2 11 | ``` 12 | 13 | ## Usage 14 | 15 | Here is a minimal example: 16 | 17 | ```ts 18 | import {Q} from 'qustar'; 19 | import {Mysql2Connector} from 'qustar-mysql2'; 20 | 21 | // create a connector for MySQL database 22 | const connector = new Mysql2Connector( 23 | 'mysql://user:password@localhost:3306/qustar' 24 | ); 25 | 26 | // construct a query 27 | const query = Q.table({ 28 | name: 'users', 29 | schema: { 30 | id: Q.i32(), 31 | }, 32 | }); 33 | 34 | // run the query using the connector 35 | const users = await query.fetch(connector); 36 | 37 | // use the result 38 | console.log(users); 39 | 40 | // close the connector 41 | await connector.close(); 42 | ``` 43 | 44 | You can also create `Mysql2Connector` by passing an instance of a `mysql2` pool: 45 | 46 | ```ts 47 | import {createPool} from 'mysql2'; 48 | import {Mysql2Connector} from 'qustar-mysql2'; 49 | 50 | const pool = createPool({ 51 | database: 'qustar', 52 | port: 3306, 53 | user: 'user', 54 | password: 'password', 55 | host: 'localhost', 56 | }); 57 | 58 | const connector = new Mysql2Connector(pool); 59 | ``` 60 | 61 | But usually it's more convenient to pass pool options directly to the connector: 62 | 63 | ```ts 64 | import {Mysql2Connector} from 'qustar-mysql2'; 65 | 66 | // connector will pass the options to Pool 67 | const connector = new Mysql2Connector({ 68 | database: 'db', 69 | port: 3306, 70 | user: 'user', 71 | password: 'password', 72 | host: 'localhost', 73 | }); 74 | ``` 75 | 76 | You can run raw SQL using a connector: 77 | 78 | ```ts 79 | // execute a statement 80 | await connector.execute('INSERT INTO users VALUES (42);'); 81 | 82 | // run a query 83 | await connector.query('SELECT 42 as meaning'); 84 | 85 | // run a parametrized query 86 | await connector.query({ 87 | sql: 'SELECT id FROM users WHERE id = ?', 88 | args: [42], 89 | }); 90 | ``` 91 | 92 | ## License 93 | 94 | MIT License, see `LICENSE`. 95 | -------------------------------------------------------------------------------- /packages/qustar-mysql2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qustar-mysql2", 3 | "version": "0.0.1", 4 | "description": "mysql2 data source for qustar", 5 | "license": "MIT", 6 | "keywords": [ 7 | "qustar", 8 | "mysql", 9 | "mysql2", 10 | "sql", 11 | "typescript" 12 | ], 13 | "main": "dist/cjs/src/index.js", 14 | "module": "dist/esm/src/index.js", 15 | "types": "dist/esm/src/index.d.ts", 16 | "files": [ 17 | "dist/esm/src", 18 | "dist/esm/package.json", 19 | "dist/cjs/src", 20 | "dist/cjs/package.json", 21 | "src" 22 | ], 23 | "exports": { 24 | ".": { 25 | "import": "./dist/esm/src/index.js", 26 | "require": "./dist/cjs/src/index.js", 27 | "default": "./dist/cjs/src/index.js" 28 | } 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/tilyupo/qustar.git", 33 | "directory": "packages/qustar-mysql2" 34 | }, 35 | "type": "module", 36 | "scripts": { 37 | "clean": "rimraf dist", 38 | "build": "tsx ../../scripts/build.ts", 39 | "dev": "tsc -w", 40 | "deploy": "tsx ../../scripts/deploy.ts", 41 | "db": "docker run -it --rm -e MYSQL_ROOT_PASSWORD=test -e MYSQL_USER=qustar -e MYSQL_PASSWORD=test -e MYSQL_DATABASE=qustar -p 22784:3306 mysql:9.0.1", 42 | "test": "vitest run" 43 | }, 44 | "peerDependencies": { 45 | "mysql2": "*", 46 | "qustar": "*" 47 | }, 48 | "devDependencies": { 49 | "npm-run-all": "^4.1.5", 50 | "qustar-testsuite": "^0.0.1", 51 | "rimraf": "^6.0.1", 52 | "tsx": "^4.17.0", 53 | "vitest": "^1.6.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/qustar-mysql2/src/index.ts: -------------------------------------------------------------------------------- 1 | export {Mysql2Connector} from './mysql2.js'; 2 | -------------------------------------------------------------------------------- /packages/qustar-mysql2/src/mysql2.ts: -------------------------------------------------------------------------------- 1 | import {Pool, RowDataPacket, createPool} from 'mysql2'; 2 | import {Connector, QuerySql, SqlCommand, renderMysql} from 'qustar'; 3 | 4 | export class Mysql2Connector implements Connector { 5 | private readonly db: Pool; 6 | 7 | constructor(connectionString: string); 8 | constructor(pool: Pool); 9 | constructor(clientOrConnectionString: Pool | string) { 10 | if (typeof clientOrConnectionString === 'string') { 11 | this.db = createPool(clientOrConnectionString); 12 | } else { 13 | this.db = clientOrConnectionString; 14 | } 15 | } 16 | 17 | render(query: QuerySql): SqlCommand { 18 | return renderMysql(query); 19 | } 20 | 21 | async execute(sql: string): Promise { 22 | await this.db.promise().execute(sql); 23 | } 24 | 25 | async query(command: SqlCommand | string): Promise { 26 | const {sql, args} = SqlCommand.derive(command); 27 | const [rows] = await this.db.promise().query(sql, args); 28 | 29 | return rows.map((row: any) => { 30 | const result: any = {}; 31 | for (const key of Object.keys(row)) { 32 | result[key] = row[key]; 33 | } 34 | return result; 35 | }); 36 | } 37 | 38 | async close(): Promise { 39 | await this.db.promise().end(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/qustar-mysql2/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function indent(s: string, depth = 1): string { 2 | return s 3 | .split('\n') 4 | .map(x => ' '.repeat(depth) + x) 5 | .join('\n'); 6 | } 7 | -------------------------------------------------------------------------------- /packages/qustar-mysql2/tests/suite.test.ts: -------------------------------------------------------------------------------- 1 | import {createInitSqlScript, describeConnector} from 'qustar-testsuite'; 2 | import {afterEach, beforeEach, describe, expect, test} from 'vitest'; 3 | import {Mysql2Connector} from '../src/mysql2.js'; 4 | 5 | describeConnector( 6 | { 7 | test, 8 | describe, 9 | expectDeepEqual: (a, b, m) => expect(a).to.deep.equal(b, m), 10 | beforeEach, 11 | afterEach, 12 | }, 13 | new Mysql2Connector('mysql://qustar:test@localhost:22784/qustar'), 14 | createInitSqlScript('mysql'), 15 | {fuzzing: false} 16 | ); 17 | -------------------------------------------------------------------------------- /packages/qustar-mysql2/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/cjs", 5 | "module": "CommonJS", 6 | }, 7 | } -------------------------------------------------------------------------------- /packages/qustar-mysql2/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./dist/esm" 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | "tests/**/*.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/qustar-pg/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-pg/.gitkeep -------------------------------------------------------------------------------- /packages/qustar-pg/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-pg/.npmignore -------------------------------------------------------------------------------- /packages/qustar-pg/README.md: -------------------------------------------------------------------------------- 1 | # qustar-pg 2 | 3 | PostgreSQL support for [qustar](https://www.npmjs.com/package/qustar) via [pg](https://www.npmjs.com/package/pg) package. 4 | 5 | ## Installation 6 | 7 | To start using `pg` with `qustar` you need to install the following packages: 8 | 9 | ``` 10 | npm install qustar qustar-pg pg 11 | ``` 12 | 13 | ## Usage 14 | 15 | Here is a minimal example: 16 | 17 | ```ts 18 | import {Q} from 'qustar'; 19 | import {PgConnector} from 'qustar-pg'; 20 | 21 | // create a connector for PostgreSQL database 22 | const connector = new PgConnector( 23 | 'postgresql://user:password@localhost:5432/qustar' 24 | ); 25 | 26 | // construct a query 27 | const query = Q.table({ 28 | name: 'users', 29 | schema: { 30 | id: Q.i32(), 31 | }, 32 | }); 33 | 34 | // run the query using the connector 35 | const users = await query.fetch(connector); 36 | 37 | // use the result 38 | console.log(users); 39 | 40 | // close the connector 41 | await connector.close(); 42 | ``` 43 | 44 | You can also create `PgConnector` by passing an instance of a `pg` pool: 45 | 46 | ```ts 47 | import {Pool} from 'pg'; 48 | import {PgConnector} from 'qustar-pg'; 49 | 50 | const pool = new Pool({ 51 | database: 'qustar', 52 | port: 5432, 53 | user: 'user', 54 | password: 'password', 55 | host: 'localhost', 56 | }); 57 | 58 | const connector = new PgConnector(pool); 59 | ``` 60 | 61 | But usually it's more convenient to pass pool options directly to the connector: 62 | 63 | ```ts 64 | import {PgConnector} from 'qustar-pg'; 65 | 66 | // connector will pass the options to pg 67 | const connector = new PgConnector({ 68 | database: 'qustar', 69 | port: 5432, 70 | user: 'user', 71 | password: 'password', 72 | host: 'localhost', 73 | }); 74 | ``` 75 | 76 | You can run raw SQL using a connector: 77 | 78 | ```ts 79 | // execute a statement 80 | await connector.execute('INSERT INTO users VALUES (42);'); 81 | 82 | // run a query 83 | await connector.query('SELECT 42 as meaning'); 84 | 85 | // run a parametrized query 86 | await connector.query({ 87 | sql: 'SELECT id FROM users WHERE id = $1', 88 | args: [42], 89 | }); 90 | ``` 91 | 92 | ## License 93 | 94 | MIT License, see `LICENSE`. 95 | -------------------------------------------------------------------------------- /packages/qustar-pg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qustar-pg", 3 | "version": "0.0.1", 4 | "description": "pg data source for qustar", 5 | "license": "MIT", 6 | "keywords": [ 7 | "qustar", 8 | "postgresql", 9 | "pg", 10 | "sql", 11 | "typescript" 12 | ], 13 | "main": "dist/cjs/src/index.js", 14 | "module": "dist/esm/src/index.js", 15 | "types": "dist/esm/src/index.d.ts", 16 | "files": [ 17 | "dist/esm/src", 18 | "dist/esm/package.json", 19 | "dist/cjs/src", 20 | "dist/cjs/package.json", 21 | "src" 22 | ], 23 | "exports": { 24 | ".": { 25 | "import": "./dist/esm/src/index.js", 26 | "require": "./dist/cjs/src/index.js", 27 | "default": "./dist/cjs/src/index.js" 28 | } 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/tilyupo/qustar.git", 33 | "directory": "packages/qustar-pg" 34 | }, 35 | "type": "module", 36 | "scripts": { 37 | "clean": "rimraf dist", 38 | "build": "tsx ../../scripts/build.ts", 39 | "dev": "tsc -w", 40 | "deploy": "tsx ../../scripts/deploy.ts", 41 | "db": "docker run -it --rm -e POSTGRES_USER=qustar -e POSTGRES_PASSWORD=test -p 22783:5432 postgres:12.20-bullseye", 42 | "test": "vitest run", 43 | "test:watch": "run-p test:watch:*", 44 | "test:watch:run": "vitest", 45 | "test:watch:db": "npm run db" 46 | }, 47 | "peerDependencies": { 48 | "pg": "*", 49 | "qustar": "*" 50 | }, 51 | "devDependencies": { 52 | "@types/pg": "^8.11.6", 53 | "npm-run-all": "^4.1.5", 54 | "qustar-testsuite": "^0.0.1", 55 | "rimraf": "^6.0.1", 56 | "tsx": "^4.17.0", 57 | "vitest": "^1.6.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/qustar-pg/src/index.ts: -------------------------------------------------------------------------------- 1 | export {PgConnector} from './pg-connector.js'; 2 | -------------------------------------------------------------------------------- /packages/qustar-pg/src/load-pg.ts: -------------------------------------------------------------------------------- 1 | async function loadPg(): Promise { 2 | if (typeof require === 'function') { 3 | return require('pg'); 4 | } else { 5 | return (await import('pg')).default; 6 | } 7 | } 8 | 9 | export {loadPg}; 10 | -------------------------------------------------------------------------------- /packages/qustar-pg/src/pg-connector.ts: -------------------------------------------------------------------------------- 1 | import {Pool} from 'pg'; 2 | import {Connector, QuerySql, SqlCommand, renderPostgresql} from 'qustar'; 3 | import {loadPg} from './load-pg.js'; 4 | 5 | export class PgConnector implements Connector { 6 | private readonly db: Promise; 7 | 8 | constructor(connectionString: string); 9 | constructor(pool: Pool); 10 | constructor(clientOrConnectionString: Pool | string) { 11 | if (typeof clientOrConnectionString === 'string') { 12 | this.db = loadPg().then( 13 | x => 14 | new x.Pool({ 15 | connectionString: clientOrConnectionString, 16 | }) 17 | ); 18 | } else { 19 | this.db = Promise.resolve(clientOrConnectionString); 20 | } 21 | } 22 | 23 | render(query: QuerySql): SqlCommand { 24 | return renderPostgresql(query); 25 | } 26 | 27 | async execute(sql: string): Promise { 28 | await (await this.db).query(sql); 29 | } 30 | 31 | async query(command: SqlCommand | string): Promise { 32 | const {sql, args} = SqlCommand.derive(command); 33 | const {rows, fields} = await (await this.db).query(sql, args); 34 | 35 | return rows.map((row: any) => { 36 | const result: any = {}; 37 | for (const key of Object.keys(row)) { 38 | const field = fields.find(x => x.name === key); 39 | if (!field) { 40 | throw new Error( 41 | `can not parse result from pg: field ${key} not found` 42 | ); 43 | } 44 | // pg returns some number types as strings to preserve accuracy 45 | // list of all dataTypeIDs can be found in the oid.txt file at the root 46 | if ( 47 | [ 48 | /* int8 */ 20, /* int2 */ 21, /* int4 */ 23, /* float4 */ 700, 49 | /* float8 */ 701, /* bit */ 1560, /* numeric */ 1700, 50 | ].includes(field.dataTypeID) && 51 | row[key] !== null 52 | ) { 53 | result[key] = Number.parseFloat(row[key]); 54 | } else { 55 | result[key] = row[key]; 56 | } 57 | } 58 | return result; 59 | }); 60 | } 61 | 62 | async close(): Promise { 63 | await (await this.db).end(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/qustar-pg/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function indent(s: string, depth = 1): string { 2 | return s 3 | .split('\n') 4 | .map(x => ' '.repeat(depth) + x) 5 | .join('\n'); 6 | } 7 | -------------------------------------------------------------------------------- /packages/qustar-pg/tests/suite.test.ts: -------------------------------------------------------------------------------- 1 | import {createInitSqlScript, describeConnector} from 'qustar-testsuite'; 2 | import {afterEach, beforeEach, describe, expect, test} from 'vitest'; 3 | import {PgConnector} from '../src/pg-connector.js'; 4 | 5 | describeConnector( 6 | { 7 | test, 8 | describe, 9 | expectDeepEqual: (a, b, m) => expect(a).to.deep.equal(b, m), 10 | beforeEach, 11 | afterEach, 12 | }, 13 | new PgConnector('postgresql://qustar:test@localhost:22783'), 14 | createInitSqlScript('postgresql').join(''), 15 | {fuzzing: false} 16 | ); 17 | -------------------------------------------------------------------------------- /packages/qustar-pg/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/cjs", 5 | "module": "CommonJS", 6 | }, 7 | } -------------------------------------------------------------------------------- /packages/qustar-pg/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./dist/esm" 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | "tests/**/*.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/qustar-sqllite3/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-sqllite3/.gitkeep -------------------------------------------------------------------------------- /packages/qustar-sqllite3/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-sqllite3/.npmignore -------------------------------------------------------------------------------- /packages/qustar-sqllite3/README.md: -------------------------------------------------------------------------------- 1 | # qustar-sqlite3 2 | 3 | SQLite support for [qustar](https://www.npmjs.com/package/qustar) via [sqlite3](https://www.npmjs.com/package/sqlite3) package. 4 | 5 | ## Installation 6 | 7 | To start using `sqlite3` with `qustar` you need to install the following packages: 8 | 9 | ``` 10 | npm install qustar qustar-sqlite3 sqlite3 11 | ``` 12 | 13 | ## Usage 14 | 15 | Here is a minimal example: 16 | 17 | ```ts 18 | import {Q} from 'qustar'; 19 | import {Sqlite3Connector} from 'qustar-sqlite3'; 20 | 21 | // create a connector for in-memory SQLite database 22 | const connector = new Sqlite3Connector(':memory:'); 23 | 24 | // construct a query 25 | const query = Q.table({ 26 | name: 'users', 27 | schema: { 28 | id: Q.i32(), 29 | }, 30 | }); 31 | 32 | // run the query using the connector 33 | const users = await query.fetch(connector); 34 | 35 | // use the result 36 | console.log(users); 37 | 38 | // close the connector 39 | await connector.close(); 40 | ``` 41 | 42 | You can also create `Sqlite3Connector` by passing an instance of `sqlite3` database: 43 | 44 | ```ts 45 | import {Database} from 'sqlite3'; 46 | 47 | // read more about more in official docs for SQLite: 48 | // https://www.sqlite.org/c3ref/c_open_autoproxy.html 49 | const db = new Database('/path/to/db.sqlite' /* mode */ 2); 50 | 51 | const connector = new Sqlite3Connector(db); 52 | ``` 53 | 54 | You can run raw SQL using a connector: 55 | 56 | ```ts 57 | // execute a statement 58 | await connector.execute('INSERT INTO users VALUES (42);'); 59 | 60 | // run a query 61 | await connector.query('SELECT 42 as meaning'); 62 | 63 | // run a parametrized query 64 | await connector.query({ 65 | sql: 'SELECT id FROM users WHERE id = ?', 66 | args: [42], 67 | }); 68 | ``` 69 | 70 | ## License 71 | 72 | MIT License, see `LICENSE`. 73 | -------------------------------------------------------------------------------- /packages/qustar-sqllite3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qustar-sqlite3", 3 | "version": "0.0.1", 4 | "description": "sqlite3 data source for qustar", 5 | "license": "MIT", 6 | "keywords": [ 7 | "qustar", 8 | "sqlite", 9 | "sqlite3", 10 | "sql", 11 | "typescript" 12 | ], 13 | "main": "dist/cjs/src/index.js", 14 | "module": "dist/esm/src/index.js", 15 | "types": "dist/esm/src/index.d.ts", 16 | "files": [ 17 | "dist/esm/src", 18 | "dist/esm/package.json", 19 | "dist/cjs/src", 20 | "dist/cjs/package.json", 21 | "src" 22 | ], 23 | "exports": { 24 | ".": { 25 | "import": "./dist/esm/src/index.js", 26 | "require": "./dist/cjs/src/index.js", 27 | "default": "./dist/cjs/src/index.js" 28 | } 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/tilyupo/qustar.git", 33 | "directory": "packages/qustar-sqlite3" 34 | }, 35 | "type": "module", 36 | "scripts": { 37 | "clean": "rimraf dist", 38 | "build": "tsx ../../scripts/build.ts", 39 | "dev": "tsc -w", 40 | "deploy": "tsx ../../scripts/deploy.ts", 41 | "test": "vitest run" 42 | }, 43 | "peerDependencies": { 44 | "qustar": "*", 45 | "sqlite3": "*" 46 | }, 47 | "devDependencies": { 48 | "qustar-testsuite": "^0.0.1", 49 | "rimraf": "^6.0.1", 50 | "tsx": "^4.17.0", 51 | "vitest": "^1.6.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/qustar-sqllite3/src/index.ts: -------------------------------------------------------------------------------- 1 | export {Sqlite3Connector} from './sqlite3-connector.js'; 2 | -------------------------------------------------------------------------------- /packages/qustar-sqllite3/src/load-sqlite3.ts: -------------------------------------------------------------------------------- 1 | async function loadSqlite3(): Promise { 2 | if (typeof require === 'function') { 3 | return require('sqlite3'); 4 | } else { 5 | return (await import('sqlite3')).default; 6 | } 7 | } 8 | 9 | export {loadSqlite3}; 10 | -------------------------------------------------------------------------------- /packages/qustar-sqllite3/src/sqlite3-connector.ts: -------------------------------------------------------------------------------- 1 | import {Connector, QuerySql, SqlCommand, renderSqlite} from 'qustar'; 2 | import type {Database} from 'sqlite3'; 3 | import {loadSqlite3} from './load-sqlite3.js'; 4 | 5 | export class Sqlite3Connector implements Connector { 6 | private readonly db: Promise; 7 | 8 | constructor(filename: string); 9 | constructor(db: Database); 10 | constructor(dbOrFilename: Database | string) { 11 | if (typeof dbOrFilename === 'string') { 12 | this.db = loadSqlite3().then(x => new x.Database(dbOrFilename)); 13 | } else { 14 | this.db = Promise.resolve(dbOrFilename); 15 | } 16 | } 17 | 18 | render(query: QuerySql): SqlCommand { 19 | return renderSqlite(query); 20 | } 21 | 22 | async execute(sql: string): Promise { 23 | const db = await this.db; 24 | return new Promise((resolve, reject) => { 25 | db.exec(sql, err => { 26 | if (err) { 27 | reject(err); 28 | } else { 29 | resolve(); 30 | } 31 | }); 32 | }); 33 | } 34 | 35 | async query(command: SqlCommand | string): Promise { 36 | const {sql, args} = SqlCommand.derive(command); 37 | const db = await this.db; 38 | return new Promise((resolve, reject) => { 39 | db.all(sql, ...args, (err: any, rows: any[]) => { 40 | if (err) { 41 | reject(err); 42 | } else { 43 | resolve(rows); 44 | } 45 | }); 46 | }); 47 | } 48 | 49 | async close(): Promise { 50 | const db = await this.db; 51 | return new Promise((resolve, reject) => 52 | db.close(err => { 53 | if (err) { 54 | reject(err); 55 | } else { 56 | resolve(); 57 | } 58 | }) 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/qustar-sqllite3/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function indent(s: string, depth = 1): string { 2 | return s 3 | .split('\n') 4 | .map(x => ' '.repeat(depth) + x) 5 | .join('\n'); 6 | } 7 | -------------------------------------------------------------------------------- /packages/qustar-sqllite3/tests/suite.test.ts: -------------------------------------------------------------------------------- 1 | import {createInitSqlScript, describeConnector} from 'qustar-testsuite'; 2 | import sqlite3 from 'sqlite3'; 3 | import {afterEach, beforeEach, describe, expect, test} from 'vitest'; 4 | import {Sqlite3Connector} from '../src/sqlite3-connector.js'; 5 | 6 | describeConnector( 7 | { 8 | test, 9 | describe, 10 | expectDeepEqual: (a, b, m) => expect(a).to.deep.equal(b, m), 11 | beforeEach, 12 | afterEach, 13 | }, 14 | new Sqlite3Connector(new sqlite3.Database(':memory:')), 15 | createInitSqlScript('sqlite'), 16 | {fuzzing: false, lateralSupport: false} 17 | ); 18 | -------------------------------------------------------------------------------- /packages/qustar-sqllite3/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/cjs", 5 | "module": "CommonJS", 6 | }, 7 | } -------------------------------------------------------------------------------- /packages/qustar-sqllite3/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./dist/esm", 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | "tests/**/*.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/qustar-testsuite/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilyupo/qustar/6a5cf152ea59dabcf699eda4ce5065e76731321f/packages/qustar-testsuite/.gitignore -------------------------------------------------------------------------------- /packages/qustar-testsuite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qustar-testsuite", 3 | "version": "0.0.1", 4 | "description": "Qustar Connector test suite", 5 | "license": "MIT", 6 | "keywords": [ 7 | "qustar", 8 | "testing", 9 | "typescript" 10 | ], 11 | "main": "dist/cjs/src/index.js", 12 | "module": "dist/esm/src/index.js", 13 | "types": "dist/esm/src/index.d.ts", 14 | "files": [ 15 | "dist/esm/src", 16 | "dist/esm/package.json", 17 | "dist/cjs/src", 18 | "dist/cjs/package.json", 19 | "src" 20 | ], 21 | "exports": { 22 | ".": { 23 | "import": "./dist/esm/src/index.js", 24 | "require": "./dist/cjs/src/index.js", 25 | "default": "./dist/cjs/src/index.js" 26 | } 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/tilyupo/qustar.git", 31 | "directory": "packages/qustar-testsuite" 32 | }, 33 | "type": "module", 34 | "scripts": { 35 | "clean": "rimraf dist", 36 | "build": "tsx ../../scripts/build.ts", 37 | "dev": "tsc -w", 38 | "deploy": "tsx ../../scripts/deploy.ts", 39 | "test": "vitest run" 40 | }, 41 | "peerDependencies": { 42 | "qustar": "*" 43 | }, 44 | "dependencies": { 45 | "ts-pattern": "^5.2.0" 46 | }, 47 | "devDependencies": { 48 | "rimraf": "^6.0.1", 49 | "tsx": "^4.17.0", 50 | "vitest": "^2.0.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/db.ts: -------------------------------------------------------------------------------- 1 | import {Dialect, Q, Query} from 'qustar'; 2 | import {TableQuery} from 'qustar/dist/esm/src/query/query'; 3 | import {match} from 'ts-pattern'; 4 | 5 | export interface User { 6 | id: number; 7 | name: string; 8 | 9 | posts: Post[]; 10 | comments: Comment[]; 11 | } 12 | 13 | export const users: Query = Q.table>({ 14 | name: 'users', 15 | schema: { 16 | id: Q.i32(), 17 | name: Q.string(), 18 | posts: Q.backRef({ 19 | references: () => posts, 20 | condition: (user, post) => user.id.eq(post.author_id), 21 | }), 22 | comments: Q.backRef({ 23 | references: () => comments, 24 | condition: (user, comment) => user.id.eq(comment.commenter_id), 25 | }), 26 | }, 27 | }); 28 | 29 | export interface Post { 30 | id: number; 31 | title: string; 32 | author_id: number; 33 | 34 | author: User; 35 | comments: Comment[]; 36 | } 37 | 38 | export const posts: Query = Q.table>({ 39 | name: 'posts', 40 | schema: { 41 | id: Q.i32(), 42 | title: Q.string(), 43 | author_id: Q.i32(), 44 | author: Q.ref({ 45 | references: () => users, 46 | condition: (post, user) => user.id.eq(post.author_id), 47 | }), 48 | comments: Q.backRef({ 49 | references: () => comments, 50 | condition: (post, comment) => post.id.eq(comment.post_id), 51 | }), 52 | }, 53 | }); 54 | 55 | export interface Comment { 56 | id: number; 57 | text: string; 58 | post_id: number; 59 | commenter_id: number; 60 | parent_id: number | null; 61 | deleted: boolean; 62 | 63 | post: Post; 64 | author: User; 65 | parent: Comment | null; 66 | } 67 | 68 | export const comments: Query = Query.table>({ 69 | name: 'comments', 70 | schema: { 71 | id: Q.i32(), 72 | text: Q.string(), 73 | post_id: Q.i32(), 74 | commenter_id: Q.i32(), 75 | parent_id: Q.i32().null(), 76 | deleted: Q.boolean(), 77 | post: Q.ref({ 78 | references: () => posts, 79 | condition: (comment, post) => post.id.eq(comment.post_id), 80 | }), 81 | author: Q.ref({ 82 | references: () => users, 83 | condition: (comment, user) => user.id.eq(comment.commenter_id), 84 | }), 85 | parent: Q.ref({ 86 | references: () => comments, 87 | condition: (comment, parent) => parent.id.eq(comment.parent_id), 88 | }).null(), 89 | }, 90 | }); 91 | 92 | export interface Job { 93 | id: number; 94 | name: string; 95 | salary: number | null; 96 | deleted: boolean; 97 | post_id: number | null; 98 | author_id: number; 99 | 100 | post: Post | null; 101 | author: User; 102 | } 103 | 104 | export const jobs: TableQuery> = Q.table>({ 105 | name: 'jobs', 106 | schema: { 107 | id: Q.i32(), 108 | name: Q.string().generated(), 109 | post_id: Q.i32().null(), 110 | author_id: Q.i32(), 111 | salary: Q.i32().null(), 112 | deleted: Q.boolean(), 113 | post: Q.ref({ 114 | references: () => posts, 115 | condition: (comment, post) => post.id.eq(comment.post_id), 116 | }).null(), 117 | author: Q.ref({ 118 | references: () => users, 119 | condition: (comment, user) => user.id.eq(comment.commenter_id), 120 | }), 121 | }, 122 | }); 123 | 124 | export function createInitSqlScript(dialect: Dialect) { 125 | const booleanType = match(dialect) 126 | .with('mysql', () => 'BOOLEAN') 127 | .with('postgresql', () => 'BOOLEAN') 128 | .with('sqlite', () => 'BIT') 129 | .exhaustive(); 130 | 131 | const trueValue = match(dialect) 132 | .with('mysql', () => 'true') 133 | .with('postgresql', () => 'true') 134 | .with('sqlite', () => '1') 135 | .exhaustive(); 136 | 137 | const falseValue = match(dialect) 138 | .with('mysql', () => 'false') 139 | .with('postgresql', () => 'false') 140 | .with('sqlite', () => '0') 141 | .exhaustive(); 142 | 143 | return /*sql*/ ` 144 | CREATE TABLE IF NOT EXISTS users ( 145 | id INT NOT NULL, 146 | name TEXT NOT NULL 147 | ); 148 | -- 149 | DELETE FROM users; 150 | -- 151 | INSERT INTO 152 | users 153 | VALUES 154 | (1, 'Dima'), 155 | (2, 'Anna'), 156 | (3, 'Max'); 157 | -- 158 | CREATE TABLE IF NOT EXISTS posts ( 159 | id INT NOT NULL, 160 | title TEXT NOT NULL, 161 | author_id INT NOT NULL 162 | ); 163 | -- 164 | DELETE FROM posts; 165 | -- 166 | INSERT INTO 167 | posts 168 | VALUES 169 | (1, 'TypeScript', 1), 170 | (2, 'rust', 1), 171 | (3, 'C#', 1), 172 | (4, 'Ruby', 2), 173 | (5, 'C++', 2), 174 | (6, 'Python', 3); 175 | -- 176 | CREATE TABLE IF NOT EXISTS comments ( 177 | id INT NOT NULL, 178 | text TEXT NOT NULL, 179 | post_id INT NOT NULL, 180 | commenter_id INT NOT NULL, 181 | deleted ${booleanType} NOT NULL, 182 | parent_id INT NULL 183 | ); 184 | -- 185 | DELETE FROM comments; 186 | -- 187 | INSERT INTO 188 | comments(id, text, post_id, commenter_id, deleted, parent_id) 189 | VALUES 190 | (5, 'cool', 1, 1, ${falseValue}, NULL), 191 | (6, '+1', 1, 1, ${falseValue}, 5), 192 | (7, 'me too', 1, 2, ${falseValue}, NULL), 193 | (8, 'nah', 2, 3, ${trueValue}, 5); 194 | -- 195 | CREATE TABLE IF NOT EXISTS jobs ( 196 | id INT NOT NULL, 197 | name TEXT NOT NULL DEFAULT 'unknown', 198 | salary INT NULL, 199 | deleted ${booleanType} NOT NULL, 200 | post_id INT NULL, 201 | author_id INT NOT NULL 202 | ); 203 | `.split('--'); 204 | } 205 | 206 | export const EXAMPLE_DB = { 207 | users: [ 208 | {id: 1, name: 'Dima'}, 209 | {id: 2, name: 'Anna'}, 210 | {id: 3, name: 'Max'}, 211 | ], 212 | posts: [ 213 | {id: 1, title: 'TypeScript', author_id: 1}, 214 | {id: 2, title: 'rust', author_id: 1}, 215 | {id: 3, title: 'C#', author_id: 1}, 216 | {id: 4, title: 'Ruby', author_id: 2}, 217 | {id: 5, title: 'C++', author_id: 2}, 218 | {id: 6, title: 'Python', author_id: 3}, 219 | ], 220 | comments: [ 221 | { 222 | id: 5, 223 | text: 'cool', 224 | post_id: 1, 225 | commenter_id: 1, 226 | deleted: 0, 227 | parent_id: null, 228 | }, 229 | { 230 | id: 6, 231 | text: '+1', 232 | post_id: 1, 233 | commenter_id: 1, 234 | deleted: 0, 235 | parent_id: 5, 236 | }, 237 | { 238 | id: 7, 239 | text: 'me too', 240 | post_id: 1, 241 | commenter_id: 2, 242 | deleted: 0, 243 | parent_id: null, 244 | }, 245 | { 246 | id: 8, 247 | text: 'nah', 248 | post_id: 2, 249 | commenter_id: 3, 250 | deleted: 1, 251 | parent_id: 5, 252 | }, 253 | ], 254 | }; 255 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/describe.ts: -------------------------------------------------------------------------------- 1 | import {Connector} from 'qustar'; 2 | import {describeCombination} from './integration/combination.js'; 3 | import {describeExpr} from './integration/expr.js'; 4 | import {describeFlatMap} from './integration/flat-map.js'; 5 | import {describeGroupBy} from './integration/group-by.js'; 6 | import {describeJoin} from './integration/join.js'; 7 | import {describeMap} from './integration/map.js'; 8 | import {describeOrder} from './integration/order.js'; 9 | import {describePagination} from './integration/pagination.js'; 10 | import {describeSql} from './integration/sql.js'; 11 | import {describeTerminator} from './integration/terminator.js'; 12 | import {describeUnique} from './integration/unique.js'; 13 | import {buildUtils, DescribeOrmUtils} from './utils.js'; 14 | 15 | export interface TestApi { 16 | test: (name: string, f: () => Promise | void) => void; 17 | describe: (name: string, f: () => Promise | void) => void; 18 | beforeEach: (fn: () => Promise) => void; 19 | afterEach: (fn: () => Promise) => void; 20 | expectDeepEqual?: (a: T, b: T, message?: string) => void; 21 | } 22 | 23 | export interface TestSuiteOptions { 24 | fuzzing: boolean; 25 | rawSql: boolean; 26 | lateralSupport: boolean; 27 | } 28 | 29 | export function describeConnectorInternal( 30 | api: TestApi, 31 | connector: Connector, 32 | initSql: string | string[], 33 | options: TestSuiteOptions 34 | ) { 35 | const migrationsApplied = (async () => { 36 | const scripts = Array.isArray(initSql) ? initSql : [initSql]; 37 | for (const script of scripts) { 38 | await connector.execute(script); 39 | } 40 | })(); 41 | 42 | const ctx: SuiteContext = { 43 | ...buildUtils(api, connector, migrationsApplied), 44 | describe: api.describe, 45 | lateralSupport: options.lateralSupport, 46 | connector, 47 | beforeEach: api.beforeEach, 48 | afterEach: api.afterEach, 49 | }; 50 | 51 | describeCombination(ctx); 52 | describeExpr(ctx); 53 | describeFlatMap(ctx); 54 | describeGroupBy(ctx); 55 | describeJoin(ctx); 56 | describeMap(ctx); 57 | describeOrder(ctx); 58 | describePagination(ctx); 59 | describeTerminator(ctx); 60 | describeUnique(ctx); 61 | 62 | if (options.rawSql) { 63 | describeSql(ctx); 64 | } 65 | 66 | if (options.fuzzing) { 67 | // todo: add fuzzing 68 | } 69 | } 70 | 71 | export function describeConnector( 72 | api: TestApi, 73 | connector: Connector, 74 | initSql: string | string[], 75 | options: Partial 76 | ) { 77 | describeConnectorInternal(api, connector, initSql, { 78 | fuzzing: true, 79 | rawSql: true, 80 | lateralSupport: options.lateralSupport ?? true, 81 | ...options, 82 | }); 83 | } 84 | 85 | export interface SuiteContext extends DescribeOrmUtils { 86 | describe: (name: string, f: () => Promise | void) => void; 87 | beforeEach: (fn: () => Promise) => void; 88 | afterEach: (fn: () => Promise) => void; 89 | lateralSupport: boolean; 90 | connector: Connector; 91 | } 92 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/expect.ts: -------------------------------------------------------------------------------- 1 | export function simpleExpectDeepEqual( 2 | actual: T, 3 | expected: T, 4 | message?: string 5 | ): void { 6 | if (!deepEqual(actual, expected)) { 7 | throw new Error( 8 | message || 9 | `Assertion failed: ${JSON.stringify(actual)} does not deeply equal ${JSON.stringify(expected)}` 10 | ); 11 | } 12 | } 13 | 14 | function deepEqual(a: any, b: any): boolean { 15 | if (a === b) { 16 | return true; // Same object or primitive value 17 | } 18 | 19 | if ( 20 | typeof a !== 'object' || 21 | typeof b !== 'object' || 22 | a === null || 23 | b === null 24 | ) { 25 | return false; // One of them is not an object or is null 26 | } 27 | 28 | if (Array.isArray(a) !== Array.isArray(b)) { 29 | return false; // One is an array and the other is not 30 | } 31 | 32 | const keysA = Object.keys(a); 33 | const keysB = Object.keys(b); 34 | 35 | if (keysA.length !== keysB.length) { 36 | return false; // Different number of keys 37 | } 38 | 39 | for (const key of keysA) { 40 | if (!keysB.includes(key)) { 41 | return false; // Different keys 42 | } 43 | 44 | if (!deepEqual(a[key], b[key])) { 45 | return false; // Different values for the same key 46 | } 47 | } 48 | 49 | return true; 50 | } 51 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/index.ts: -------------------------------------------------------------------------------- 1 | export {createInitSqlScript} from './db.js'; 2 | export {describeConnector} from './describe.js'; 3 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/combination.ts: -------------------------------------------------------------------------------- 1 | import {posts, users} from '../db.js'; 2 | import {SuiteContext} from '../describe.js'; 3 | 4 | export function describeCombination({ 5 | test, 6 | expectQuery, 7 | describe, 8 | }: SuiteContext) { 9 | describe('query', async () => { 10 | describe('combination', () => { 11 | test('string union all', async () => { 12 | const lhs = users.map(x => x.name); 13 | const rhs = posts.map(x => x.title); 14 | const query = lhs 15 | .unionAll(rhs) 16 | .orderByAsc(x => x) 17 | .limit(3); 18 | 19 | await expectQuery(query, ['Anna', 'C#', 'C++']); 20 | }); 21 | 22 | test('object union all', async () => { 23 | const lhs = users.map(x => ({id: x.id, name: x.name})); 24 | const rhs = posts.map(x => ({id: x.id, name: x.title})); 25 | const query = lhs 26 | .unionAll(rhs) 27 | .orderByAsc(x => x.name) 28 | .limit(3); 29 | 30 | await expectQuery(query, [ 31 | {id: 2, name: 'Anna'}, 32 | {id: 3, name: 'C#'}, 33 | {id: 5, name: 'C++'}, 34 | ]); 35 | }); 36 | 37 | test('concat (asc, desc)', async () => { 38 | const lhs = users.map(x => x.id).orderByAsc(x => x); 39 | const rhs = posts.map(x => x.id).orderByDesc(x => x); 40 | const query = lhs.concat(rhs); 41 | 42 | await expectQuery(query, [1, 2, 3, 6, 5, 4, 3, 2, 1]); 43 | }); 44 | 45 | test('concat (desc, asc)', async () => { 46 | const lhs = users.map(x => x.id).orderByDesc(x => x); 47 | const rhs = posts.map(x => x.id).orderByAsc(x => x); 48 | const query = lhs.concat(rhs); 49 | 50 | await expectQuery(query, [3, 2, 1, 1, 2, 3, 4, 5, 6]); 51 | }); 52 | }); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/expr.ts: -------------------------------------------------------------------------------- 1 | import {Expr, Q, sql} from 'qustar'; 2 | import {comments, users} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | import {ExecuteOptions} from '../utils.js'; 5 | 6 | export function describeExpr({expectQuery, test, describe}: SuiteContext) { 7 | describe('expr', () => { 8 | function testExpr( 9 | name: string, 10 | expr: Expr, 11 | expected: any, 12 | options?: ExecuteOptions 13 | ) { 14 | test(name, async () => { 15 | const query = users 16 | .orderByAsc(x => x.id) 17 | .drop(1) 18 | .limit(1) 19 | .map(() => expr); 20 | 21 | await expectQuery(query, [expected], options); 22 | }); 23 | } 24 | 25 | describe('binary', () => { 26 | testExpr('2 + 4 is 6', Expr.add(2, 4), 6); 27 | testExpr('2 + 4.2 is 6.2', Expr.add(2, 4.2), 6.2); 28 | testExpr('2 + null is null', Expr.add(2, null), null); 29 | testExpr('null + 2 is null', Expr.add(null, 2), null); 30 | 31 | describe('sub', () => { 32 | testExpr('2 - 4 is -2', Expr.sub(2, 4), -2); 33 | testExpr('2.2 - 4 is -1.8', Expr.sub(2.4, 4), -1.6); 34 | testExpr('2 - null is null', Expr.sub(2, null), null); 35 | testExpr('null - 2 is null', Expr.sub(null, 2), null); 36 | }); 37 | 38 | describe('mul', () => { 39 | testExpr('2 * 4 is 8', Expr.mul(2, 4), 8); 40 | testExpr('2 * 4.2 is 8.4', Expr.mul(2, 4.2), 8.4); 41 | testExpr('2 * null is null', Expr.mul(2, null), null); 42 | testExpr('null * 2 is null', Expr.mul(null, 2), null); 43 | }); 44 | 45 | describe('div', () => { 46 | testExpr('2 / 2 is 1', Expr.div(2, 2), 1); 47 | testExpr('7 / 2 is 3', Expr.div(7, 2), 3.5); 48 | testExpr('7.5 / 2 is 3.75', Expr.div(7.5, 2), 3.75); 49 | testExpr('null / 2 is null', Expr.div(null, 2), null); 50 | testExpr('7.2 / null is null', Expr.div(7.2, null), null); 51 | }); 52 | 53 | describe('eq', () => { 54 | testExpr('2 == 2 is true', Expr.eq(2, 2), true); 55 | testExpr('2 == 5 is false', Expr.eq(2, 5), false); 56 | testExpr('2 == null is false', Expr.eq(2, null), false); 57 | testExpr('null == 2 is false', Expr.eq(null, 2), false); 58 | testExpr('null == null is true', Expr.eq(null, null), true, { 59 | optOnly: true, 60 | }); 61 | }); 62 | 63 | describe('ne', () => { 64 | testExpr('2 != 5 is true', Expr.ne(2, 5), true); 65 | testExpr('2 != 2 is false', Expr.ne(2, 2), false); 66 | testExpr('2 != null is true', Expr.ne(2, null), true, { 67 | optOnly: true, 68 | }); 69 | testExpr("null != 'foo' is true", Expr.ne(null, 'foo'), true, { 70 | optOnly: true, 71 | }); 72 | testExpr('null != null is false', Expr.ne(null, null), false, { 73 | optOnly: true, 74 | }); 75 | }); 76 | 77 | testExpr('true and true is true', Expr.and(true, true), true); 78 | testExpr('true and false is false', Expr.and(true, false), false); 79 | testExpr('false and true is false', Expr.and(false, true), false); 80 | testExpr('false and false is false', Expr.and(false, false), false); 81 | testExpr('null and true is false', Expr.and(null, true), false); 82 | testExpr('false and null is false', Expr.and(false, null), false); 83 | testExpr('true and null is false', Expr.and(true, null), false); 84 | testExpr('null and false is false', Expr.and(null, false), false); 85 | testExpr('null and null is null', Expr.and(null, null), false); 86 | 87 | testExpr('true or true is true', Expr.or(true, true), true); 88 | testExpr('true or false is true', Expr.or(true, false), true); 89 | testExpr('false or true is true', Expr.or(false, true), true); 90 | testExpr('false or false is false', Expr.or(false, false), false); 91 | testExpr('null or true is true', Expr.or(null, true), true); 92 | testExpr('false or null is false', Expr.or(false, null), false); 93 | testExpr('true or null is true', Expr.or(true, null), true); 94 | testExpr('null or false is false', Expr.or(null, false), false); 95 | testExpr('null or null is false', Expr.or(null, false), false); 96 | 97 | describe('gt', () => { 98 | testExpr('0 > 1 is false', Expr.gt(0, 1), false); 99 | testExpr('1 > 1 is false', Expr.gt(1, 1), false); 100 | testExpr('2 > 1 is true', Expr.gt(2, 1), true); 101 | testExpr('null > 1 is false', Expr.gt(null, 1), false); 102 | testExpr('2 > null is false', Expr.gt(2, null), false); 103 | }); 104 | 105 | describe('lt', () => { 106 | testExpr('0 < 1 is 0', Expr.lt(0, 1), true); 107 | testExpr('1 < 1 is 0', Expr.lt(1, 1), false); 108 | testExpr('2 < 1 is 0', Expr.lt(2, 1), false); 109 | testExpr('null < 1 is false', Expr.lt(null, 1), false); 110 | testExpr('2 < null is false', Expr.lt(2, null), false); 111 | }); 112 | 113 | describe('lte', () => { 114 | testExpr('0 <= 1 is 1', Expr.lte(0, 1), true); 115 | testExpr('1 <= 1 is 1', Expr.lte(1, 1), true); 116 | testExpr('2 <= 1 is 0', Expr.lte(2, 1), false); 117 | testExpr('null <= 1 is false', Expr.lte(null, 1), false); 118 | testExpr('2 <= null is false', Expr.lte(2, null), false); 119 | }); 120 | 121 | describe('gte', () => { 122 | testExpr('0 >= 1 is 1', Expr.gte(0, 1), false); 123 | testExpr('1 >= 1 is 1', Expr.gte(1, 1), true); 124 | testExpr('2 >= 1 is 0', Expr.gte(2, 1), true); 125 | testExpr('null >= 1 is false', Expr.gte(null, 1), false); 126 | testExpr('2 >= null is false', Expr.gte(2, null), false); 127 | }); 128 | 129 | testExpr("'123' like '12%' is 1", Expr.like('123', '12%'), true); 130 | testExpr("'123' like '12' is 0", Expr.like('123', '12'), false); 131 | testExpr("null like '12%' is 0", Expr.like(null, '12%'), false); 132 | testExpr("'123' like null is 0", Expr.like('123', null), false); 133 | 134 | testExpr('1 in (1, 2, 3) is 1', Expr.in(1, [1, 2, 3]), true); 135 | testExpr('1 in (2, 3) is 0', Expr.in(1, [2, 3]), false); 136 | testExpr('null in (1, 2, 3) is 0', Expr.in(null, [1, 2, 3]), false); 137 | }); 138 | 139 | describe('unary', () => { 140 | testExpr('-1 is -1', Expr.negate(1), -1); 141 | testExpr('-(-1) is 1', Expr.negate(-1), 1); 142 | testExpr('-null is null', Expr.negate(null), null, { 143 | optOnly: true, 144 | }); 145 | 146 | testExpr('!true is false', Expr.not(true), false); 147 | testExpr('!false is true', Expr.not(false), true); 148 | testExpr('!null is true', Expr.not(null), true, {optOnly: true}); 149 | }); 150 | 151 | describe('sql', () => { 152 | testExpr('1 + 1', Q.rawExpr({sql: sql`1 + 1`, schema: Q.i32()}), 2); 153 | testExpr( 154 | '1 + NULL', 155 | Q.rawExpr({sql: sql`1 + NULL`, schema: Q.i32().null()}), 156 | null 157 | ); 158 | testExpr('1 + ?', Q.rawExpr({sql: sql`1 + ${2}`, schema: Q.i32()}), 3); 159 | testExpr( 160 | 'SELECT 2', 161 | Expr.rawExpr({sql: sql`SELECT ${2}`, schema: Q.i32()}), 162 | 2 163 | ); 164 | 165 | test('${comments.id} + 1', async () => { 166 | const query = comments 167 | .map(x => Expr.rawExpr({sql: sql`${x.id} + 1`, schema: Q.i32()})) 168 | .orderByAsc(x => x); 169 | 170 | await expectQuery(query, [6, 7, 8, 9]); 171 | }); 172 | }); 173 | 174 | describe('case', () => { 175 | testExpr( 176 | "case 1 when 1 then 'one' when 2 then 'two' end is 'one'", 177 | Expr.case(1, [ 178 | {condition: 1, result: 'one'}, 179 | {condition: 2, result: 'two'}, 180 | ]), 181 | 'one' 182 | ); 183 | 184 | testExpr( 185 | "case 2 when 1 then 'one' when 2 then 'two' end is 'two'", 186 | Expr.case(2, [ 187 | {condition: 1, result: 'one'}, 188 | {condition: 2, result: 'two'}, 189 | ]), 190 | 'two' 191 | ); 192 | 193 | testExpr( 194 | "case 3 when 1 then 'one' when 2 then 'two' else 'none' end is 'none'", 195 | Expr.case( 196 | 3, 197 | [ 198 | {condition: 1, result: 'one'}, 199 | {condition: 2, result: 'two'}, 200 | ], 201 | 'none' 202 | ), 203 | 'none' 204 | ); 205 | }); 206 | 207 | describe('literal', () => { 208 | // todo: add date, time, timetz, timestamp, timestamptz, uuid 209 | testExpr('1 is 1', Expr.from(1), 1); 210 | testExpr("'one' is 'one'", Expr.from('one'), 'one'); 211 | testExpr('"\'` is "\'`', Expr.from('"\'`'), '"\'`'); 212 | testExpr('null is null', Expr.from(null), null); 213 | testExpr('1.23 is 1.23', Expr.from(1.23), 1.23); 214 | testExpr('true is true', Expr.from(true), true); 215 | testExpr('false is false', Expr.from(false), false); 216 | }); 217 | 218 | describe('func', () => { 219 | describe('substring', () => { 220 | testExpr( 221 | "substring('01234', 1, 3) is '12'", 222 | Expr.substring(Expr.from('01234'), 1, 3), 223 | '12' 224 | ); 225 | testExpr( 226 | "substring(null, 1, 3) is '12'", 227 | Expr.substring(null, 1, 3), 228 | null 229 | ); 230 | testExpr( 231 | "substring('01234', null, 3) is null", 232 | Expr.substring(Expr.from('01234'), null, 3), 233 | null 234 | ); 235 | testExpr( 236 | "substring('01234', 1, null) is null", 237 | Expr.substring(Expr.from('01234'), 1, null), 238 | null 239 | ); 240 | }); 241 | 242 | describe('toLowerCase', () => { 243 | testExpr( 244 | 'TypeScript is typescript', 245 | Expr.toLowerCase(Expr.from('TypeScript')), 246 | 'typescript' 247 | ); 248 | testExpr( 249 | 'lower is lower', 250 | Expr.toLowerCase(Expr.from('lower')), 251 | 'lower' 252 | ); 253 | testExpr( 254 | 'UPPER is upper', 255 | Expr.toLowerCase(Expr.from('UPPER')), 256 | 'upper' 257 | ); 258 | testExpr('null is null', Expr.toLowerCase(Expr.from(null)), null); 259 | }); 260 | 261 | describe('toUpperCase', () => { 262 | testExpr( 263 | 'TypeScript is TYPESCRIPT', 264 | Expr.toUpperCase(Expr.from('TypeScript')), 265 | 'TYPESCRIPT' 266 | ); 267 | testExpr( 268 | 'lower is LOWER', 269 | Expr.toUpperCase(Expr.from('lower')), 270 | 'LOWER' 271 | ); 272 | testExpr( 273 | 'UPPER is UPPER', 274 | Expr.toUpperCase(Expr.from('UPPER')), 275 | 'UPPER' 276 | ); 277 | testExpr('null is null', Expr.toUpperCase(Expr.from(null)), null); 278 | }); 279 | 280 | describe('toString', () => { 281 | testExpr('toString(null) is null', Expr.from(null).toString(), null); 282 | // todo: add new Date() toString test 283 | testExpr( 284 | "toString('some text') is 'some text'", 285 | Expr.from('some text').toString(), 286 | 'some text' 287 | ); 288 | testExpr( 289 | "toString(1234) is '1234'", 290 | Expr.from(1234).toString(), 291 | '1234' 292 | ); 293 | testExpr( 294 | "toString(true) is 'true'", 295 | Expr.from(true).toString(), 296 | 'true' 297 | ); 298 | testExpr( 299 | "toString(false) is 'false'", 300 | Expr.from(false).toString(), 301 | 'false' 302 | ); 303 | }); 304 | 305 | describe('toInt', () => { 306 | testExpr('toInt(null) is null', Expr.from(null).toInt(), null); 307 | testExpr("toInt('1234') is 1234", Expr.from('1234').toInt(), 1234); 308 | testExpr('toInt(1234) is 1234', Expr.from(1234).toInt(), 1234); 309 | testExpr('toInt(234.456) is 234', Expr.from(234.456).toInt(), 234); 310 | testExpr('toInt(true) is 1', Expr.from(true).toInt(), 1); 311 | testExpr('toInt(false) is 0', Expr.from(false).toInt(), 0); 312 | }); 313 | 314 | describe('toFloat', () => { 315 | testExpr('toFloat(null) is null', Expr.from(null).toFloat(), null); 316 | testExpr("toFloat('1234') is 1234", Expr.from('1234').toFloat(), 1234); 317 | testExpr( 318 | "toFloat('234.567') is 234.567", 319 | Expr.from('234.567').toFloat(), 320 | 234.567 321 | ); 322 | testExpr('toFloat(1234) is 1234', Expr.from(1234).toFloat(), 1234); 323 | testExpr( 324 | 'toFloat(234.567) is 234.567', 325 | Expr.from(234.567).toFloat(), 326 | 234.567 327 | ); 328 | }); 329 | }); 330 | 331 | describe('composition', () => { 332 | testExpr('(3 + 6) / 3 is 3', Expr.div(Expr.add(3, 6), 3), 3); 333 | testExpr( 334 | '1 >= 2 and 4 == 4 is false', 335 | Expr.and(Expr.gte(1, 2), Expr.eq(4, 4)), 336 | false 337 | ); 338 | testExpr( 339 | '1 <= 2 and 4 == 4 is false', 340 | Expr.and(Expr.lte(1, 2), Expr.eq(4, 4)), 341 | true 342 | ); 343 | testExpr( 344 | '1 >= 2 or 4 == 4 is false', 345 | Expr.or(Expr.gte(1, 2), Expr.eq(4, 4)), 346 | true 347 | ); 348 | }); 349 | }); 350 | } 351 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/filter.ts: -------------------------------------------------------------------------------- 1 | import {FilterFn} from 'qustar'; 2 | import {comments, posts} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | import {Post} from '../utils.js'; 5 | 6 | export function describeFilter({ 7 | describe, 8 | test, 9 | expectQuery, 10 | testFactory, 11 | }: SuiteContext) { 12 | describe('query', () => { 13 | describe('filter by', () => { 14 | const testFilter = testFactory((filter: FilterFn) => { 15 | return posts 16 | .filter(filter) 17 | .orderByAsc(x => x.id) 18 | .map(x => x.title) 19 | .limit(3); 20 | }); 21 | 22 | testFilter('true', () => true, ['TypeScript', 'rust', 'C#']); 23 | testFilter('false', () => false, []); 24 | testFilter('expression', x => x.id.lte(2), ['TypeScript', 'rust']); 25 | testFilter('author name', x => x.author.name.eq('Dima'), [ 26 | 'TypeScript', 27 | 'rust', 28 | 'C#', 29 | ]); 30 | testFilter('author id', x => x.author.id.eq(2), ['Ruby', 'C++']); 31 | testFilter('author comments count', x => x.author.comments.size().eq(1), [ 32 | 'Ruby', 33 | 'C++', 34 | 'Python', 35 | ]); 36 | 37 | test("comment parent text != 'never'", async () => { 38 | const query = comments 39 | .filter(x => x.parent.text.ne('never')) 40 | .map(x => x.id); 41 | 42 | await expectQuery(query, [5, 6, 7, 8]); 43 | }); 44 | }); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/flat-map.ts: -------------------------------------------------------------------------------- 1 | import {posts, users} from '../db.js'; 2 | import {SuiteContext} from '../describe.js'; 3 | 4 | export function describeFlatMap({ 5 | describe, 6 | test, 7 | expectQuery, 8 | lateralSupport, 9 | }: SuiteContext) { 10 | describe('query', () => { 11 | describe('flatMap', () => { 12 | if (lateralSupport) { 13 | // there was a problem with ambiguous column selection because 14 | // system ordering __orm_system__ordering__0 was specified twice 15 | test('user post ids', async () => { 16 | const query = users 17 | .flatMap(x => x.posts.orderByAsc(x => x.id)) 18 | .orderByAsc(x => x.id) 19 | .map(x => x.id); 20 | 21 | await expectQuery(query, [1, 2, 3, 4, 5, 6]); 22 | }); 23 | } 24 | 25 | test('user posts (order by title)', async () => { 26 | const query = users 27 | .orderByDesc(x => x.id) 28 | .flatMap(x => x.posts.orderByAsc(x => x.id).map(y => y.title)) 29 | .orderByAsc(x => x.toLowerCase()); 30 | 31 | await expectQuery( 32 | query, 33 | ['C#', 'C++', 'Python', 'Ruby', 'rust', 'TypeScript'], 34 | {optOnly: !lateralSupport} 35 | ); 36 | }); 37 | 38 | test('user posts (preserve flat map order)', async () => { 39 | const query = users 40 | .orderByDesc(x => x.id) 41 | .flatMap(x => x.posts.orderByAsc(x => x.id).map(y => y.title)); 42 | 43 | await expectQuery( 44 | query, 45 | ['Python', 'Ruby', 'C++', 'TypeScript', 'rust', 'C#'], 46 | {optOnly: !lateralSupport} 47 | ); 48 | }); 49 | 50 | test('user posts preserve ordering', async () => { 51 | const query = users 52 | .orderByAsc(x => x.id) 53 | .flatMap(x => x.posts.map(y => y.id).orderByDesc(x => x)); 54 | 55 | await expectQuery(query, [3, 2, 1, 5, 4, 6], { 56 | optOnly: !lateralSupport, 57 | }); 58 | }); 59 | 60 | test('comments boolean deleted', async () => { 61 | const query = users 62 | .flatMap(x => x.comments.map(x => ({id: x.id, deleted: x.deleted}))) 63 | .orderByAsc(x => x.id) 64 | .map(x => x.deleted); 65 | 66 | await expectQuery(query, [false, false, false, true], { 67 | optOnly: !lateralSupport, 68 | }); 69 | }); 70 | 71 | test('nested refs', async () => { 72 | const query = posts 73 | .orderByAsc(x => x.id) 74 | .flatMap(post => 75 | post.comments 76 | .orderByAsc(x => x.id) 77 | .map(comment => ({ 78 | author: comment.author.id, 79 | })) 80 | ) 81 | .map(x => x.author); 82 | 83 | await expectQuery(query, [1, 1, 2, 3], { 84 | optOnly: !lateralSupport, 85 | }); 86 | }); 87 | }); 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/group-by.ts: -------------------------------------------------------------------------------- 1 | import {Expr, FilterFn, MapValueFn, Mapping} from 'qustar'; 2 | import {posts} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | 5 | export function describeGroupBy({describe, testFactory}: SuiteContext) { 6 | describe('query', () => { 7 | describe('groupBy', () => { 8 | describe('aggregation', () => { 9 | const testGroupBy = testFactory( 10 | (mapper: MapValueFn) => { 11 | return posts 12 | .groupBy({ 13 | by: x => x.author_id, 14 | select: x => ({author_id: x.author_id, value: mapper(x.id)}), 15 | }) 16 | .orderByAsc(x => x.author_id) 17 | .map(x => x.value); 18 | } 19 | ); 20 | 21 | testGroupBy('count', () => Expr.count(1), [3, 2, 1]); 22 | testGroupBy('max', x => Expr.max(x), [3, 5, 6]); 23 | testGroupBy('min', x => Expr.min(x), [1, 4, 6]); 24 | testGroupBy('sum', x => Expr.sum(x), [6, 9, 6]); 25 | testGroupBy('avg', x => Expr.average(x), [2, 4.5, 6]); 26 | testGroupBy('average', x => Expr.average(x), [2, 4.5, 6]); 27 | }); 28 | 29 | describe('having', () => { 30 | const testHaving = testFactory((having: FilterFn) => { 31 | return posts 32 | .groupBy({ 33 | by: x => x.author_id, 34 | select: x => x.author_id, 35 | having: x => having(x.id), 36 | }) 37 | .orderByAsc(x => x) 38 | .map(x => x); 39 | }); 40 | 41 | testHaving('count', () => Expr.count(1).gte(2), [1, 2]); 42 | testHaving('max', x => Expr.max(x).lt(5), [1]); 43 | testHaving('min', x => Expr.min(x).lt(5), [1, 2]); 44 | testHaving('sum', x => Expr.sum(x).gt(7), [2]); 45 | testHaving('average', x => Expr.average(x).ne(4.5), [1, 3]); 46 | }); 47 | }); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/join.ts: -------------------------------------------------------------------------------- 1 | import {comments, posts, users} from '../db.js'; 2 | import {SuiteContext} from '../describe.js'; 3 | 4 | export function describeJoin({describe, expectQuery, test}: SuiteContext) { 5 | describe('query', () => { 6 | describe('join', () => { 7 | test('inner', async () => { 8 | const query = posts 9 | .innerJoin({ 10 | right: users, 11 | select: (post, user) => ({title: post.title, name: user.name}), 12 | condition: (post, user) => post.author_id.eq(user.id), 13 | }) 14 | .orderByAsc(x => x.title) 15 | .limit(3); 16 | 17 | await expectQuery(query, [ 18 | {name: 'Dima', title: 'C#'}, 19 | {name: 'Anna', title: 'C++'}, 20 | {name: 'Max', title: 'Python'}, 21 | ]); 22 | }); 23 | 24 | test('left', async () => { 25 | const query = comments 26 | .leftJoin({ 27 | right: comments, 28 | select: (child, parent) => parent.id, 29 | condition: (child, parent) => child.parent_id.eq(parent.id), 30 | }) 31 | .filter(x => x.ne(1)) 32 | .orderByAsc(x => x); 33 | 34 | await expectQuery(query, [null, null, 5, 5]); 35 | }); 36 | 37 | test('left', async () => { 38 | const query = posts 39 | .leftJoin({ 40 | right: users, 41 | condition: (post, user) => post.id.eq(user.id), 42 | select: (post, user) => ({post, user}), 43 | }) 44 | .orderByAsc(x => x.post.id) 45 | .thenByAsc(x => x.user.id); 46 | 47 | await expectQuery(query, []); 48 | }); 49 | 50 | test('right', async () => { 51 | const query = comments 52 | .rightJoin({ 53 | right: comments, 54 | select: child => child.id, 55 | condition: (child, parent) => child.parent_id.eq(parent.id), 56 | }) 57 | .filter(x => x.ne(1)) 58 | .orderByAsc(x => x); 59 | 60 | await expectQuery(query, [null, null, null, 6, 8]); 61 | }); 62 | }); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/map.ts: -------------------------------------------------------------------------------- 1 | import {MapValueFn} from 'qustar'; 2 | import {comments, posts} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | import {Post} from '../utils.js'; 5 | 6 | export function describeMap({ 7 | describe, 8 | expectQuery, 9 | testFactory, 10 | test, 11 | }: SuiteContext) { 12 | describe('query', () => { 13 | describe('map to', () => { 14 | const testMap = testFactory( 15 | // todo: use generic (workaround for typescript): 16 | // (mapper: MapValueFn) => { 17 | (mapper: MapValueFn) => { 18 | return [ 19 | posts 20 | .orderByAsc(x => x.id) 21 | .map(mapper) 22 | .limit(1, 1), 23 | posts 24 | .orderByAsc(x => x.id) 25 | .limit(1, 1) 26 | .map(mapper), 27 | posts 28 | .limit(1, 1) 29 | .orderByAsc(x => x.id) 30 | .map(mapper), 31 | ]; 32 | } 33 | ); 34 | 35 | test('use ref after map', async () => { 36 | const query = comments 37 | .map(x => x.post) 38 | .map(x => x.author) 39 | .orderByAsc(x => x.id) 40 | .map(x => x.name) 41 | .limit(3); 42 | 43 | await expectQuery(query, ['Dima', 'Dima', 'Dima']); 44 | }); 45 | 46 | testMap('boolean', () => true, true); 47 | testMap('number', () => 3, 3); 48 | testMap('string', () => 'str', 'str'); 49 | testMap('string', () => 'str', 'str'); 50 | testMap('null', () => null, null); 51 | testMap('id column', x => x.id, 2); 52 | // testMap('unselection', x => ({...x, title: undefined}), { 53 | // id: 2, 54 | // author_id: 1, 55 | // }); 56 | testMap('title column', x => x.title, 'rust'); 57 | testMap('expression', x => x.id.add(3), 5); 58 | testMap('author name', x => x.author.name, 'Dima'); 59 | testMap('author comments count', x => x.author.comments.size(), 2); 60 | testMap( 61 | 'object', 62 | x => ({ 63 | new_id: x.id.mul(3), 64 | text: x.title.concat(' ').concat(x.author.name), 65 | }), 66 | {new_id: 6, text: 'rust Dima'} 67 | ); 68 | testMap('author', x => x.author, {id: 1, name: 'Dima'}); 69 | testMap('self', x => x, {id: 2, title: 'rust', author_id: 1}); 70 | testMap('spread post with literal', x => ({...x, x: 100500}), { 71 | id: 2, 72 | title: 'rust', 73 | author_id: 1, 74 | x: 100500, 75 | }); 76 | testMap( 77 | 'spread post with author', 78 | x => ({...x, x: 123, ...x.author, y: x.author.name}), 79 | { 80 | id: 1, 81 | title: 'rust', 82 | author_id: 1, 83 | x: 123, 84 | name: 'Dima', 85 | y: 'Dima', 86 | } 87 | ); 88 | 89 | testMap( 90 | 'nested ref', 91 | x => ({one: {id: x.id, title: x.title}, two: x.author}), 92 | {one: {id: 2, title: 'rust'}, two: {id: 1, name: 'Dima'}} 93 | ); 94 | 95 | testMap( 96 | 'nested nested ref', 97 | x => ({one: {id: x.id, lvl1: {lvl2: {lvl3: 1}}}}), 98 | {one: {id: 2, lvl1: {lvl2: {lvl3: 1}}}} 99 | ); 100 | 101 | test('use nested ref', async () => { 102 | const query = comments 103 | .map(x => ({post: x.post})) 104 | .map(x => x.post.author.name); 105 | 106 | await expectQuery(query, ['Dima', 'Dima', 'Dima', 'Dima']); 107 | }); 108 | 109 | testMap('special symbols', () => ({'%&""*"-+': 1}), {'%&""*"-+': 1}); 110 | 111 | test('two refs with the same name', async () => { 112 | const query = comments 113 | .orderByAsc(x => x.id) 114 | .map(x => ({...x.post, ...x})) 115 | .map(x => x.author.name); 116 | 117 | await expectQuery(query, ['Dima', 'Dima', 'Anna', 'Max']); 118 | }); 119 | 120 | test('two refs with the same name', async () => { 121 | const query = comments 122 | .map(x => ({...x, ...x.post})) 123 | .map(x => x.author.name); 124 | 125 | await expectQuery(query, ['Dima', 'Dima', 'Dima', 'Dima']); 126 | }); 127 | }); 128 | }); 129 | } 130 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/order.ts: -------------------------------------------------------------------------------- 1 | import {MapScalarFn, ScalarMapping} from 'qustar'; 2 | import {posts} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | import {Post} from '../utils.js'; 5 | 6 | export function describeOrder({ 7 | describe, 8 | expectQuery, 9 | testFactory, 10 | test, 11 | }: SuiteContext) { 12 | describe('query', () => { 13 | describe('order by', () => { 14 | const testOrderBy = testFactory( 15 | (orderBy: MapScalarFn) => { 16 | return posts 17 | .orderByAsc(orderBy) 18 | .limit(3) 19 | .map(x => x.title); 20 | } 21 | ); 22 | 23 | testOrderBy('id', x => x.id, ['TypeScript', 'rust', 'C#']); 24 | testOrderBy('name', x => x.title, ['C#', 'C++', 'Python']); 25 | testOrderBy('author.name', x => x.author.name.concat(x.id.toString()), [ 26 | 'Ruby', 27 | 'C++', 28 | 'TypeScript', 29 | ]); 30 | 31 | test('user id asc post id desc', async () => { 32 | const query = posts 33 | .orderByAsc(x => x.author_id) 34 | .thenByDesc(x => x.id) 35 | .map(x => x.id); 36 | 37 | await expectQuery(query, [3, 2, 1, 5, 4, 6]); 38 | }); 39 | 40 | test('user id desc post id asc', async () => { 41 | const query = posts 42 | .orderByDesc(x => x.author_id) 43 | .thenByAsc(x => x.id) 44 | .map(x => x.id); 45 | 46 | await expectQuery(query, [6, 4, 5, 1, 2, 3]); 47 | }); 48 | 49 | test('order by select', async () => { 50 | const query = posts.map(x => x.comments.size()).orderByAsc(x => x); 51 | 52 | await expectQuery(query, [0, 0, 0, 0, 1, 3]); 53 | }); 54 | }); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/pagination.ts: -------------------------------------------------------------------------------- 1 | import {Query} from 'qustar'; 2 | import {posts} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | import {Post} from '../utils.js'; 5 | 6 | export function describePagination({describe, testFactory}: SuiteContext) { 7 | describe('query', () => { 8 | describe('pagination [1, 2, 3, 4, 5, 6]', () => { 9 | const testPagination = testFactory( 10 | (limits: [limit: number, offset: number | undefined][]) => { 11 | return limits 12 | .reduce( 13 | (q, [limit, offset]) => q.limit(limit, offset), 14 | posts.orderByAsc(x => x.id) as Query 15 | ) 16 | .map(x => x.id); 17 | } 18 | ); 19 | 20 | testPagination('(0, 0)', [[0, 0]], []); 21 | testPagination('(9, 0)', [[9, 0]], [1, 2, 3, 4, 5, 6]); 22 | testPagination('(9, )', [[9, undefined]], [1, 2, 3, 4, 5, 6]); 23 | testPagination('(2, )', [[2, undefined]], [1, 2]); 24 | testPagination('(3, 0)', [[3, 0]], [1, 2, 3]); 25 | testPagination('(3, 2)', [[3, 2]], [3, 4, 5]); 26 | testPagination('(3, 6)', [[6, 2]], [3, 4, 5, 6]); 27 | testPagination('(3, 6)', [[6, 2]], [3, 4, 5, 6]); 28 | testPagination( 29 | '(3, 2) (1 2)', 30 | [ 31 | [3, 2], 32 | [1, 2], 33 | ], 34 | [5] 35 | ); 36 | testPagination( 37 | '(3, 1) (6 2)', 38 | [ 39 | [5, 1], 40 | [6, 2], 41 | ], 42 | [4, 5, 6] 43 | ); 44 | testPagination( 45 | '(3, 1) (6 2)', 46 | [ 47 | [5, 1], 48 | [2, 2], 49 | ], 50 | [4, 5] 51 | ); 52 | }); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/sql.ts: -------------------------------------------------------------------------------- 1 | import {Q, sql} from 'qustar'; 2 | import {users} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | 5 | export function describeSql({describe, expectQuery, test}: SuiteContext) { 6 | describe('query', () => { 7 | describe('sql', () => { 8 | test('SELECT 1 as value', async () => { 9 | const query = Q.rawQuery({ 10 | sql: sql`SELECT 1 as value`, 11 | schema: { 12 | value: Q.i32(), 13 | }, 14 | }); 15 | 16 | await expectQuery(query, [{value: 1}]); 17 | }); 18 | 19 | test('row_number', async () => { 20 | const query = Q.rawQuery({ 21 | sql: sql` 22 | SELECT 23 | p.id, 24 | ROW_NUMBER () OVER (PARTITION BY p.author_id ORDER BY p.id) AS idx 25 | FROM 26 | posts AS p 27 | ORDER BY 28 | p.id 29 | `, 30 | schema: { 31 | id: Q.i32(), 32 | idx: Q.i32(), 33 | }, 34 | }).map(x => ({...x, idx: x.idx.sub(1)})); 35 | 36 | await expectQuery(query, [ 37 | {id: 1, idx: 0}, 38 | {id: 2, idx: 1}, 39 | {id: 3, idx: 2}, 40 | {id: 4, idx: 0}, 41 | {id: 5, idx: 1}, 42 | {id: 6, idx: 0}, 43 | ]); 44 | }); 45 | 46 | test('subquery', async () => { 47 | const query = users 48 | .orderByAsc(x => x.id) 49 | .map(x => 50 | Q.rawQuery({ 51 | sql: sql`SELECT * FROM posts as p WHERE p.author_id = ${x.id}`, 52 | schema: { 53 | id: Q.i32(), 54 | }, 55 | }).sum(x => x.id) 56 | ); 57 | 58 | await expectQuery(query, [6, 9, 6]); 59 | }); 60 | 61 | test('schema', async () => { 62 | const query = Q.rawQuery({ 63 | sql: sql`SELECT * FROM posts`, 64 | schema: { 65 | author_id: Q.i32(), 66 | id: Q.i32(), 67 | author: Q.ref({ 68 | references: () => users, 69 | condition: (post, user) => post.author_id.eq(user.id), 70 | }), 71 | }, 72 | }) 73 | .orderByAsc(x => x.id) 74 | .map(x => x.author.id); 75 | 76 | await expectQuery(query, [1, 1, 1, 2, 2, 3]); 77 | }); 78 | }); 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/terminator.ts: -------------------------------------------------------------------------------- 1 | import {Query, QueryTerminatorExpr, SingleLiteralValue} from 'qustar'; 2 | import {posts} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | import {Post} from '../utils.js'; 5 | 6 | export function describeTerminator({describe, testFactory}: SuiteContext) { 7 | describe('query', () => { 8 | describe('terminator', () => { 9 | const testTerm = testFactory( 10 | ( 11 | mapper: (q: Query) => QueryTerminatorExpr 12 | ) => { 13 | return mapper(posts); 14 | } 15 | ); 16 | 17 | describe('max', () => { 18 | testTerm('id', posts => posts.max(x => x.id), 6); 19 | testTerm('author.id', posts => posts.max(x => x.author.id), 3); 20 | }); 21 | 22 | describe('min', () => { 23 | testTerm('id', posts => posts.min(x => x.id), 1); 24 | testTerm( 25 | 'id (with order)', 26 | posts => posts.orderByAsc(x => x.id).min(x => x.id), 27 | 1 28 | ); 29 | testTerm('author.id', posts => posts.min(x => x.author.id), 1); 30 | }); 31 | 32 | describe('size', () => { 33 | testTerm('id', posts => posts.size(), 6); 34 | testTerm( 35 | 'id (with order)', 36 | posts => posts.orderByAsc(x => x.id).size(), 37 | 6 38 | ); 39 | testTerm('title', posts => posts.limit(2).size(), 2); 40 | testTerm( 41 | 'comments', 42 | posts => posts.flatMap(x => x.comments).size(), 43 | 4, 44 | { 45 | optOnly: true, 46 | } 47 | ); 48 | }); 49 | 50 | describe('mean', () => { 51 | testTerm( 52 | 'id', 53 | posts => posts.average(x => x.id), 54 | (1 + 2 + 3 + 4 + 5 + 6) / 6 55 | ); 56 | testTerm( 57 | 'id (with order)', 58 | posts => posts.orderByAsc(x => x.id).average(x => x.id), 59 | (1 + 2 + 3 + 4 + 5 + 6) / 6 60 | ); 61 | testTerm( 62 | 'author.id', 63 | posts => posts.average(x => x.author.id.mul(3).div(2)), 64 | ((1 + 1 + 1 + 2 + 2 + 3) * 3) / 2 / 6 65 | ); 66 | }); 67 | 68 | describe('sum', () => { 69 | testTerm('id', posts => posts.sum(x => x.id), 1 + 2 + 3 + 4 + 5 + 6); 70 | testTerm( 71 | 'id (with order)', 72 | posts => posts.orderByAsc(x => x.id).sum(x => x.id), 73 | 1 + 2 + 3 + 4 + 5 + 6 74 | ); 75 | testTerm( 76 | 'author.id', 77 | posts => posts.sum(x => x.author.id), 78 | 1 + 1 + 1 + 2 + 2 + 3 79 | ); 80 | }); 81 | 82 | describe('some', () => { 83 | testTerm('true', posts => posts.some(), true); 84 | testTerm( 85 | 'false', 86 | posts => posts.filter(x => x.id.eq(-1)).some(), 87 | false 88 | ); 89 | }); 90 | 91 | describe('empty', () => { 92 | testTerm('true', posts => posts.empty(), false); 93 | testTerm( 94 | 'true (with order)', 95 | posts => posts.orderByDesc(x => x.id).empty(), 96 | false 97 | ); 98 | testTerm( 99 | 'false', 100 | posts => posts.filter(x => x.id.eq(-1)).empty(), 101 | true 102 | ); 103 | }); 104 | 105 | describe('first', () => { 106 | testTerm( 107 | 'first comment order by id', 108 | posts => 109 | posts 110 | .map(post => 111 | post.comments.orderByDesc(x => x.id).first(x => x.id) 112 | ) 113 | .sum(x => x), 114 | 15 115 | ); 116 | testTerm( 117 | 'comment id sum', 118 | posts => posts.map(post => post.comments.sum(x => x.id)).sum(x => x), 119 | 26 120 | ); 121 | }); 122 | }); 123 | }); 124 | } 125 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/integration/unique.ts: -------------------------------------------------------------------------------- 1 | import {MapValueFn, ScalarMapping} from 'qustar'; 2 | import {posts} from '../db.js'; 3 | import {SuiteContext} from '../describe.js'; 4 | import {Post} from '../utils.js'; 5 | 6 | export function describeUnique({describe, testFactory}: SuiteContext) { 7 | describe('query', () => { 8 | describe('unique', () => { 9 | const testUnique = testFactory( 10 | (mapper: MapValueFn) => { 11 | return posts 12 | .map(mapper) 13 | .unique() 14 | .orderByAsc(x => x); 15 | } 16 | ); 17 | 18 | testUnique('id', x => x.id, [1, 2, 3, 4, 5, 6] as any); 19 | testUnique('id / 2', x => x.id.div(2).toInt(), [0, 1, 2, 3] as any); 20 | testUnique('author name', x => x.author.name, [ 21 | 'Anna', 22 | 'Dima', 23 | 'Max', 24 | ] as any); 25 | }); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/src/utils.ts: -------------------------------------------------------------------------------- 1 | import {mkdirSync, writeFileSync} from 'fs'; 2 | import { 3 | Connector, 4 | Query, 5 | QueryTerminatorExpr, 6 | SingleLiteralValue, 7 | compileQuery, 8 | interpretQuery, 9 | materialize, 10 | optimize, 11 | renderSqlite, 12 | } from 'qustar'; 13 | import {Stmt} from 'qustar/dist/esm/src/query/query.js'; 14 | import {Comment, EXAMPLE_DB, Post, User} from './db.js'; 15 | import {TestApi} from './describe.js'; 16 | import {simpleExpectDeepEqual} from './expect.js'; 17 | 18 | export {Comment, Post, User}; 19 | 20 | function indent(s: string, depth = 1): string { 21 | return s 22 | .split('\n') 23 | .map(x => ' '.repeat(depth) + x) 24 | .join('\n'); 25 | } 26 | 27 | export function queryToSql(query: Query | QueryTerminatorExpr) { 28 | const compiledQuery = compileQuery(query, {parameters: false}); 29 | const optimizedQuery = optimize(compiledQuery); 30 | const renderedQuery = renderSqlite(optimizedQuery); 31 | 32 | return renderedQuery.sql; 33 | } 34 | 35 | export interface ExecuteOptions { 36 | readonly optOnly?: boolean; 37 | readonly rawOnly?: boolean; 38 | readonly debug?: boolean; 39 | readonly ignoreOrder?: boolean; 40 | readonly checkInterpret?: boolean; 41 | } 42 | 43 | function canonSort(arr: T[]) { 44 | arr.sort((a: any, b: any) => { 45 | const aKey = JSON.stringify(a); 46 | const bKey = JSON.stringify(b); 47 | 48 | if (aKey < bKey) { 49 | return -1; 50 | } else { 51 | return 1; 52 | } 53 | }); 54 | } 55 | 56 | export function dump( 57 | query: Query | QueryTerminatorExpr, 58 | token = 'dump' 59 | ) { 60 | token = `${token}-${new Date().getTime()}`; 61 | mkdirSync(`./debug/${token}`); 62 | const compiledQuery = compileQuery(query); 63 | writeFileSync( 64 | `./debug/${token}/sql-raw.json`, 65 | JSON.stringify(compiledQuery, undefined, 2) 66 | ); 67 | writeFileSync( 68 | `./debug/${token}/query-raw.sql`, 69 | renderSqlite(compiledQuery).sql 70 | ); 71 | 72 | const optimizedQuery = optimize(compiledQuery); 73 | writeFileSync( 74 | `./debug/${token}/sql-opt.json`, 75 | JSON.stringify(optimizedQuery, undefined, 2) 76 | ); 77 | 78 | const renderedQuery = renderSqlite(optimizedQuery); 79 | writeFileSync(`./debug/${token}/query-opt.sql`, renderedQuery.sql); 80 | writeFileSync( 81 | `./debug/${token}/args.json`, 82 | JSON.stringify(renderedQuery.args, undefined, 2) 83 | ); 84 | } 85 | 86 | export interface DescribeOrmUtils { 87 | execute( 88 | query: Query | QueryTerminatorExpr | Stmt, 89 | options?: ExecuteOptions 90 | ): Promise; 91 | expectQuery( 92 | query: Query | QueryTerminatorExpr, 93 | expected: any, 94 | options?: ExecuteOptions 95 | ): Promise; 96 | test(name: string, f: () => Promise, options?: ExecuteOptions): void; 97 | testFactory( 98 | f: ( 99 | param: Param 100 | ) => 101 | | Query 102 | | QueryTerminatorExpr 103 | | Array | QueryTerminatorExpr> 104 | ): ( 105 | name: string, 106 | arg: Param, 107 | expected: Result | Result[], 108 | options?: ExecuteOptions 109 | ) => void; 110 | } 111 | 112 | export function buildUtils( 113 | testApi: TestApi, 114 | connector: Connector, 115 | migrationsApplied: Promise 116 | ): DescribeOrmUtils { 117 | const test = testApi.test; 118 | const expectDeepEqual = testApi.expectDeepEqual ?? simpleExpectDeepEqual; 119 | async function checkProvider( 120 | query: Query | QueryTerminatorExpr, 121 | expectedRows: any[] | undefined, 122 | options?: ExecuteOptions 123 | ) { 124 | await migrationsApplied; 125 | if (!connector) { 126 | return; 127 | } 128 | 129 | if (options?.optOnly && options.rawOnly) { 130 | throw new Error( 131 | 'invalid execute options: at least opt or raw must be allowed' 132 | ); 133 | } 134 | 135 | const projection = 136 | query instanceof Query ? query.projection : query.projection(); 137 | let sql = compileQuery(query, {parameters: false}); 138 | if (options?.optOnly) { 139 | sql = optimize(sql); 140 | } 141 | const referenceCommand = connector.render(sql); 142 | const referenceRows = await connector 143 | .query(referenceCommand) 144 | .then((rows: any[]) => rows.map(x => materialize(x, projection))); 145 | 146 | if (options?.ignoreOrder) { 147 | canonSort(referenceRows); 148 | } 149 | 150 | if (expectedRows !== undefined) { 151 | expectDeepEqual(referenceRows, expectedRows); 152 | } 153 | 154 | for (const withOptimization of [true, false]) { 155 | if (options?.rawOnly && withOptimization) continue; 156 | if (options?.optOnly && !withOptimization) continue; 157 | 158 | for (const parameters of [true, false]) { 159 | let sql = compileQuery(query, {parameters}); 160 | if (withOptimization) { 161 | sql = optimize(sql); 162 | } 163 | 164 | const command = connector.render(sql); 165 | const rows = await connector 166 | .query(command) 167 | .then((rows: any[]) => rows.map(x => materialize(x, projection))); 168 | 169 | if (options?.ignoreOrder) { 170 | canonSort(rows); 171 | } 172 | 173 | try { 174 | expectDeepEqual(rows, referenceRows); 175 | } catch (err: any) { 176 | err.message += '\n\nROWS MISSMATCH!'; 177 | err.message += '\n\ncmd:'; 178 | err.message += 179 | '\n\n rows: ' + indent(JSON.stringify(rows, null, 2)).trim(); 180 | err.message += indent( 181 | '\nargs: ' + JSON.stringify(command.args) + '\n\n' + command.sql 182 | ); 183 | 184 | err.message += '\n\nref:'; 185 | err.message += 186 | '\n\n rows: ' + 187 | indent(JSON.stringify(referenceRows, null, 2)).trim(); 188 | err.message += indent( 189 | '\nargs: ' + 190 | JSON.stringify(referenceCommand.args) + 191 | '\n\n' + 192 | referenceCommand.sql 193 | ); 194 | throw err; 195 | } 196 | } 197 | } 198 | 199 | return referenceRows; 200 | } 201 | 202 | async function execute( 203 | query: Query | QueryTerminatorExpr, 204 | options?: ExecuteOptions 205 | ): Promise { 206 | const expectedRows = 207 | (options?.checkInterpret ?? false) 208 | ? query instanceof Query 209 | ? interpretQuery(query, {db: EXAMPLE_DB}) 210 | : interpretQuery(query, {db: EXAMPLE_DB}) 211 | : undefined; 212 | 213 | if (options?.ignoreOrder && expectedRows) { 214 | canonSort(expectedRows); 215 | } 216 | 217 | const result = await checkProvider(query, expectedRows, options); 218 | 219 | if (result === undefined && expectedRows === undefined) { 220 | throw new Error('must checkInterpret or have a provider'); 221 | } 222 | 223 | return (expectedRows ?? result)!; 224 | } 225 | 226 | async function expectQuery(query, expected, options) { 227 | const rows = await execute(query, options); 228 | 229 | if (options?.debug) { 230 | dump(query); 231 | } 232 | 233 | if (options?.ignoreOrder) { 234 | canonSort(rows); 235 | canonSort(expected); 236 | } 237 | 238 | try { 239 | expectDeepEqual(rows, expected); 240 | } catch (err: any) { 241 | err.message += '\n\n' + queryToSql(query); 242 | throw err; 243 | } 244 | } 245 | 246 | return { 247 | execute, 248 | expectQuery, 249 | test(name, f) { 250 | test(name, async () => { 251 | await f(); 252 | }); 253 | }, 254 | testFactory(f) { 255 | return (name, arg, expected, options) => { 256 | test(name, async () => { 257 | let queries = f(arg); 258 | if (!Array.isArray(queries)) { 259 | queries = [queries]; 260 | } 261 | 262 | for (const query of queries) { 263 | await expectQuery( 264 | query, 265 | Array.isArray(expected) ? expected : [expected], 266 | options 267 | ); 268 | } 269 | }); 270 | }; 271 | }, 272 | }; 273 | } 274 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {simpleExpectDeepEqual} from '../src/expect.js'; 3 | 4 | describe('assertDeepEqual', () => { 5 | it('should not throw an error for deeply equal primitives', () => { 6 | expect(() => simpleExpectDeepEqual(1, 1)).not.toThrow(); 7 | expect(() => simpleExpectDeepEqual('hello', 'hello')).not.toThrow(); 8 | expect(() => simpleExpectDeepEqual(true, true)).not.toThrow(); 9 | }); 10 | 11 | it('should throw an error for non-equal primitives', () => { 12 | expect(() => simpleExpectDeepEqual(1, 2)).toThrow(); 13 | expect(() => simpleExpectDeepEqual('hello', 'world')).toThrow(); 14 | expect(() => simpleExpectDeepEqual(true, false)).toThrow(); 15 | }); 16 | 17 | it('should not throw an error for deeply equal objects', () => { 18 | expect(() => 19 | simpleExpectDeepEqual({a: 1, b: 2}, {a: 1, b: 2}) 20 | ).not.toThrow(); 21 | expect(() => simpleExpectDeepEqual({a: {b: 2}}, {a: {b: 2}})).not.toThrow(); 22 | }); 23 | 24 | it('should throw an error for non-equal objects', () => { 25 | expect(() => simpleExpectDeepEqual({a: 1, b: 2}, {a: 1, b: 3})).toThrow(); 26 | expect(() => simpleExpectDeepEqual({a: {b: 2}}, {a: {b: 3}})).toThrow(); 27 | }); 28 | 29 | it('should not throw an error for deeply equal arrays', () => { 30 | expect(() => simpleExpectDeepEqual([1, 2, 3], [1, 2, 3])).not.toThrow(); 31 | expect(() => simpleExpectDeepEqual([1, [2, 3]], [1, [2, 3]])).not.toThrow(); 32 | }); 33 | 34 | it('should throw an error for non-equal arrays', () => { 35 | expect(() => simpleExpectDeepEqual([1, 2, 3], [1, 2, 4])).toThrow(); 36 | expect(() => simpleExpectDeepEqual([1, [2, 3]], [1, [2, 4]])).toThrow(); 37 | }); 38 | 39 | it('should not throw an error for deeply equal mixed objects and arrays', () => { 40 | expect(() => 41 | simpleExpectDeepEqual({a: [1, 2], b: {c: 3}}, {a: [1, 2], b: {c: 3}}) 42 | ).not.toThrow(); 43 | }); 44 | 45 | it('should throw an error for non-equal mixed objects and arrays', () => { 46 | expect(() => 47 | simpleExpectDeepEqual({a: [1, 2], b: {c: 3}}, {a: [1, 2], b: {c: 4}}) 48 | ).toThrow(); 49 | }); 50 | 51 | it('should correctly handle null and undefined', () => { 52 | expect(() => simpleExpectDeepEqual(null, null)).not.toThrow(); 53 | expect(() => simpleExpectDeepEqual(undefined, undefined)).not.toThrow(); 54 | expect(() => simpleExpectDeepEqual(null, undefined)).toThrow(); 55 | expect(() => simpleExpectDeepEqual({a: null}, {a: null})).not.toThrow(); 56 | expect(() => 57 | simpleExpectDeepEqual({a: undefined}, {a: undefined}) 58 | ).not.toThrow(); 59 | expect(() => simpleExpectDeepEqual({a: null}, {a: undefined})).toThrow(); 60 | }); 61 | 62 | it('should correctly compare nested structures', () => { 63 | const obj1 = {a: {b: {c: [1, 2, 3]}}}; 64 | const obj2 = {a: {b: {c: [1, 2, 3]}}}; 65 | expect(() => simpleExpectDeepEqual(obj1, obj2)).not.toThrow(); 66 | }); 67 | 68 | it('should throw an error with a custom message', () => { 69 | const customMessage = 'Custom assertion error'; 70 | expect(() => simpleExpectDeepEqual(1, 2, customMessage)).toThrow( 71 | customMessage 72 | ); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/todo.yaml: -------------------------------------------------------------------------------- 1 | - remove vitest dependency, use some lib for assertions 2 | - add first terminator tests 3 | - add fetchFirst tests 4 | - add query terminator fetch tests 5 | -------------------------------------------------------------------------------- /packages/qustar-testsuite/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/cjs", 5 | "module": "CommonJS", 6 | }, 7 | } -------------------------------------------------------------------------------- /packages/qustar-testsuite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./dist/esm" 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | "tests/**/*.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/qustar-testsuite/vitest.config.ts: -------------------------------------------------------------------------------- 1 | export {default} from '../../vitest.config.js'; 2 | -------------------------------------------------------------------------------- /packages/qustar/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qustar", 3 | "version": "0.0.1", 4 | "description": "TypeScript SQL query builder", 5 | "license": "MIT", 6 | "keywords": [ 7 | "sql", 8 | "typescript", 9 | "db", 10 | "query", 11 | "orm" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/tilyupo/qustar.git", 16 | "directory": "packages/qustar" 17 | }, 18 | "main": "dist/cjs/src/index.js", 19 | "module": "dist/esm/src/index.js", 20 | "types": "dist/esm/src/index.d.ts", 21 | "files": [ 22 | "dist/esm/src", 23 | "dist/esm/package.json", 24 | "dist/cjs/src", 25 | "dist/cjs/package.json", 26 | "src" 27 | ], 28 | "exports": { 29 | ".": { 30 | "import": "./dist/esm/src/index.js", 31 | "require": "./dist/cjs/src/index.js", 32 | "default": "./dist/cjs/src/index.js" 33 | } 34 | }, 35 | "type": "module", 36 | "scripts": { 37 | "clean": "rimraf dist", 38 | "build": "tsx ../../scripts/build.ts", 39 | "dev": "tsc -w", 40 | "deploy": "cp ../../README.md ./ && tsx ../../scripts/deploy.ts && rm ./README.md", 41 | "test": "vitest run" 42 | }, 43 | "devDependencies": { 44 | "rimraf": "^6.0.1", 45 | "tsx": "^4.17.0", 46 | "typedoc": "^0.26.5", 47 | "vitest": "^1.6.0" 48 | }, 49 | "dependencies": { 50 | "ts-pattern": "^5.2.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/qustar/src/connector.ts: -------------------------------------------------------------------------------- 1 | import {LiteralValue, ScalarType} from './literal.js'; 2 | import { 3 | SCALAR_COLUMN_ALIAS, 4 | SYSTEM_COLUMN_PREFIX, 5 | deserializePropPath, 6 | } from './query/compiler.js'; 7 | import {isNumeric} from './query/expr.js'; 8 | import {Projection, PropPath} from './query/projection.js'; 9 | import {Sql} from './sql/sql.js'; 10 | import { 11 | arrayEqual, 12 | assert, 13 | deepEntries, 14 | isNumberString, 15 | setPath, 16 | } from './utils.js'; 17 | 18 | export interface SqlCommand { 19 | readonly sql: string; 20 | readonly args: LiteralValue[]; 21 | } 22 | 23 | export namespace SqlCommand { 24 | export function derive(command: SqlCommand | string): SqlCommand { 25 | if (typeof command === 'string') { 26 | return { 27 | sql: command, 28 | args: [], 29 | }; 30 | } else { 31 | return command; 32 | } 33 | } 34 | 35 | export function join(queries: SqlCommand[], sep = ''): SqlCommand { 36 | return { 37 | sql: queries.map(x => x.sql).join(sep), 38 | args: queries.flatMap(x => x.args), 39 | }; 40 | } 41 | } 42 | 43 | export interface Connector { 44 | render(query: Sql): SqlCommand; 45 | query(command: SqlCommand | string): Promise; 46 | execute(sql: string): Promise; 47 | close(): Promise; 48 | } 49 | 50 | interface FlatRowColumn { 51 | path: PropPath; 52 | value: any; 53 | } 54 | 55 | type FlatRow = FlatRowColumn[]; 56 | 57 | function createSlice(row: FlatRow, prefix: string) { 58 | const slice: FlatRow = []; 59 | 60 | for (const {path, value} of row) { 61 | if (path[0] === prefix) { 62 | slice.push({path: path.slice(1), value}); 63 | } 64 | } 65 | 66 | return slice; 67 | } 68 | 69 | function flatRowToObject(row: FlatRow): unknown { 70 | const result: any = {}; 71 | 72 | const props = new Set(row.map(({path}) => path[0])); 73 | 74 | for (const prop of props) { 75 | const slice = createSlice(row, prop); 76 | if ( 77 | slice.findIndex(({path}) => path.length === 0) !== -1 && 78 | slice.length > 1 79 | ) { 80 | throw new Error('invalid FlatRow: ' + JSON.stringify(row, null, 2)); 81 | } 82 | 83 | if (slice.length === 1 && slice[0].path.length === 0) { 84 | result[prop] = slice[0].value; 85 | } else { 86 | result[prop] = flatRowToObject(slice); 87 | } 88 | } 89 | 90 | return result; 91 | } 92 | 93 | export function materialize(row: any | null, projection: Projection): any { 94 | assert(row !== undefined, 'invalid row: ' + row); 95 | 96 | if (row === null) { 97 | if (projection.nullable) { 98 | return null; 99 | } else { 100 | throw new Error('got null for non-nullable row'); 101 | } 102 | } 103 | 104 | for (const key of Object.keys(row)) { 105 | if (key.startsWith(SYSTEM_COLUMN_PREFIX)) { 106 | delete row[key]; 107 | } 108 | } 109 | 110 | // todo: combine with deep entries? 111 | row = flatRowToObject( 112 | Object.entries(row).map( 113 | ([prop, value]): FlatRowColumn => ({ 114 | path: deserializePropPath(prop), 115 | value, 116 | }) 117 | ) 118 | ); 119 | 120 | function materializeBoolean(value: unknown) { 121 | if (typeof value === 'boolean') { 122 | return value; 123 | } 124 | if (value !== 0 && value !== 1) { 125 | throw new Error('can not materialize boolean value: ' + value); 126 | } 127 | 128 | return value === 1; 129 | } 130 | 131 | function materializeNumber(value: unknown) { 132 | if (typeof value === 'number') { 133 | return value; 134 | } 135 | 136 | if (typeof value !== 'string' || !isNumberString(value)) { 137 | throw new Error('can not materialize number value: ' + value); 138 | } 139 | 140 | return Number.parseFloat(value); 141 | } 142 | 143 | function materializeScalar(value: unknown, scalarType: ScalarType) { 144 | if (value === null) { 145 | if (scalarType.nullable) { 146 | return null; 147 | } else { 148 | throw new Error('got null for a non null scalar type'); 149 | } 150 | } 151 | 152 | if (scalarType.type === 'boolean') { 153 | return materializeBoolean(value); 154 | } 155 | 156 | if (isNumeric(scalarType)) { 157 | return materializeNumber(value); 158 | } 159 | 160 | return value; 161 | } 162 | 163 | return projection.visit({ 164 | scalar: scalar => { 165 | const value = row[SCALAR_COLUMN_ALIAS]; 166 | 167 | return materializeScalar(value, scalar.scalarType); 168 | }, 169 | object: object => { 170 | const result: Record = {}; 171 | for (const [path, value] of deepEntries(row)) { 172 | const propProj = object.props.find(x => arrayEqual(x.path, path)); 173 | assert(propProj !== undefined, 'got an unknown prop path'); 174 | setPath(result, path, materializeScalar(value, propProj.scalarType)); 175 | } 176 | 177 | return result; 178 | }, 179 | }); 180 | } 181 | 182 | export function cmd( 183 | template: TemplateStringsArray, 184 | ...expr: Array 185 | ): SqlCommand { 186 | const parts: string[] = [template[0]]; 187 | const args: LiteralValue[] = []; 188 | 189 | for (let i = 1; i < template.length; i += 1) { 190 | const currentExpr = expr[i - 1]; 191 | if (typeof currentExpr === 'number' || typeof currentExpr === 'string') { 192 | parts.push(currentExpr.toString()); 193 | } else { 194 | parts.push(currentExpr.sql); 195 | args.push(...currentExpr.args); 196 | } 197 | parts.push(template[i]); 198 | } 199 | 200 | return { 201 | sql: parts.join(''), 202 | args, 203 | }; 204 | } 205 | -------------------------------------------------------------------------------- /packages/qustar/src/descriptor.ts: -------------------------------------------------------------------------------- 1 | import {SingleLiteral, SingleLiteralValue, SingleScalarType} from './literal'; 2 | import {Query} from './query/query'; 3 | import {Field, Ref, Schema} from './query/schema'; 4 | import {IsAny, JoinFilterFn} from './types/query'; 5 | import {assert} from './utils'; 6 | 7 | export interface RefOptions { 8 | readonly references: () => Query; 9 | readonly condition: JoinFilterFn; 10 | } 11 | 12 | export interface ScalarPropType { 13 | readonly type: 'scalar'; 14 | readonly scalarTypeType: Exclude; 15 | } 16 | 17 | export interface ForwardRefPropType { 18 | readonly type: 'forward_ref'; 19 | readonly options: RefOptions; 20 | } 21 | 22 | export interface BackRefPropType { 23 | readonly type: 'back_ref'; 24 | readonly options: RefOptions; 25 | } 26 | 27 | export type PropType = ScalarPropType | ForwardRefPropType | BackRefPropType; 28 | 29 | export class Prop< 30 | TJsType, 31 | TIsGenerated extends boolean, 32 | TIsRef extends boolean, 33 | > { 34 | __jsType?: TJsType; 35 | __jsNull?: null extends TJsType ? 1 : 0; 36 | 37 | static i8(): Prop { 38 | return new Prop({type: 'scalar', scalarTypeType: 'i8'}, false, false); 39 | } 40 | static i16(): Prop { 41 | return new Prop({type: 'scalar', scalarTypeType: 'i16'}, false, false); 42 | } 43 | static i32(): Prop { 44 | return new Prop({type: 'scalar', scalarTypeType: 'i32'}, false, false); 45 | } 46 | static i64(): Prop { 47 | return new Prop({type: 'scalar', scalarTypeType: 'i64'}, false, false); 48 | } 49 | static f32(): Prop { 50 | return new Prop({type: 'scalar', scalarTypeType: 'f32'}, false, false); 51 | } 52 | static f64(): Prop { 53 | return new Prop({type: 'scalar', scalarTypeType: 'f64'}, false, false); 54 | } 55 | static string(): Prop { 56 | return new Prop({type: 'scalar', scalarTypeType: 'string'}, false, false); 57 | } 58 | static boolean(): Prop { 59 | return new Prop({type: 'scalar', scalarTypeType: 'boolean'}, false, false); 60 | } 61 | 62 | /** 63 | * Will be removed in the next release. 64 | * @deprecated 65 | */ 66 | static ref(options: RefOptions): Prop { 67 | return new Prop({type: 'forward_ref', options}, false, false); 68 | } 69 | 70 | /** 71 | * Will be removed in the next release. 72 | * @deprecated 73 | */ 74 | static backRef(options: RefOptions): Prop { 75 | return new Prop({type: 'back_ref', options}, false, false); 76 | } 77 | 78 | constructor( 79 | public readonly type: PropType, 80 | public readonly nullable: boolean, 81 | public readonly isGenerated: boolean 82 | ) {} 83 | 84 | null(): Prop { 85 | assert(this.type.type !== 'back_ref', 'back ref cannot be nullable'); 86 | 87 | return new Prop(this.type, true, this.isGenerated); 88 | } 89 | 90 | notNull(): Prop { 91 | return new Prop(this.type, true, this.isGenerated); 92 | } 93 | 94 | generated(): Prop { 95 | return new Prop(this.type, this.nullable, true); 96 | } 97 | } 98 | 99 | export type EntityDescriptor = Record>; 100 | 101 | export interface Table { 102 | readonly name: string; 103 | readonly schema: TSchema; 104 | } 105 | 106 | export type DeriveEntity = { 107 | [K in keyof T]: DeriveEntityPropertyValue; 108 | }; 109 | 110 | export type DeriveInsertEntity = { 111 | // required non ref/generated fields 112 | [K in keyof T as T[K] extends Prop 113 | ? TIsGen extends true 114 | ? never 115 | : TIsRef extends true 116 | ? never 117 | : null extends T[K] 118 | ? never 119 | : K 120 | : never]: T[K] extends Prop ? TType : never; 121 | } & { 122 | // optional generated fields 123 | [K in keyof T as T[K] extends Prop 124 | ? TIsGen extends true 125 | ? K 126 | : null extends T[K] 127 | ? K 128 | : never 129 | : never]?: T[K] extends Prop ? TType : never; 130 | }; 131 | 132 | export type DeriveEntityPropertyValue> = 133 | IsAny extends true 134 | ? any 135 | : T extends Prop 136 | ? TType 137 | : never; 138 | 139 | export type DeriveEntityDescriptor = { 140 | [K in keyof T]: Prop; 141 | }; 142 | 143 | type ConsolidateBoolean = T extends boolean ? boolean : T; 144 | 145 | export type ScalarDescriptor< 146 | T extends SingleLiteralValue = SingleLiteralValue, 147 | TActual = Exclude, 148 | TNull = null extends T ? null : never, 149 | > = 150 | | (TActual extends any 151 | ? Prop | TNull, any, any> 152 | : never) 153 | | (TActual extends any ? Prop, any, any> : never); 154 | 155 | export function scalarDescriptorToScalarType( 156 | prop: ScalarDescriptor 157 | ): SingleScalarType { 158 | assert( 159 | prop.type.type === 'scalar', 160 | 'expected scalar schema, but got: ' + prop.type.type 161 | ); 162 | 163 | return { 164 | type: prop.type.scalarTypeType, 165 | nullable: prop.nullable, 166 | }; 167 | } 168 | 169 | export function toSchema( 170 | table: () => Query, 171 | columns: EntityDescriptor 172 | ): Schema { 173 | const descriptors = Object.entries(columns ?? {}).map(([name, prop]) => ({ 174 | name, 175 | type: prop.type, 176 | nullable: prop.nullable, 177 | isGenerated: prop.isGenerated, 178 | })); 179 | const scalarDescriptors = descriptors 180 | .filter(({type}) => type.type === 'scalar') 181 | .map(descriptor => { 182 | return {...descriptor, type: descriptor.type as ScalarPropType}; 183 | }); 184 | if (scalarDescriptors.length === 0) { 185 | throw new Error('schema must define at least one field'); 186 | } 187 | 188 | return { 189 | fields: scalarDescriptors.map( 190 | ({isGenerated, name, nullable, type}): Field => ({ 191 | name, 192 | isGenerated, 193 | scalarType: { 194 | type: type.scalarTypeType, 195 | nullable, 196 | }, 197 | }) 198 | ), 199 | refs: [ 200 | ...descriptors 201 | .filter(({type}) => type.type === 'forward_ref') 202 | .map(descriptor => { 203 | return { 204 | ...descriptor, 205 | type: descriptor.type as ForwardRefPropType, 206 | }; 207 | }) 208 | .map( 209 | (x): Ref => ({ 210 | type: 'forward_ref', 211 | child: table, 212 | parent: x.type.options.references, 213 | nullable: x.nullable, 214 | condition: (a, b) => x.type.options.condition(b, a), 215 | path: [x.name], 216 | }) 217 | ), 218 | ...descriptors 219 | .filter(({type}) => type.type === 'back_ref') 220 | .map(descriptor => { 221 | return { 222 | ...descriptor, 223 | type: descriptor.type as BackRefPropType, 224 | }; 225 | }) 226 | .map( 227 | (x): Ref => ({ 228 | type: 'back_ref', 229 | child: x.type.options.references, 230 | nullable: false, 231 | parent: table, 232 | condition: x.type.options.condition, 233 | path: [x.name], 234 | }) 235 | ), 236 | ], 237 | }; 238 | } 239 | -------------------------------------------------------------------------------- /packages/qustar/src/index.ts: -------------------------------------------------------------------------------- 1 | export {Connector, SqlCommand, materialize} from './connector.js'; 2 | export {Literal, LiteralValue, SingleLiteralValue} from './literal.js'; 3 | export {compileQuery} from './query/compiler.js'; 4 | export {Expr, QueryTerminatorExpr} from './query/expr.js'; 5 | export {Dialect, Query} from './query/query.js'; 6 | export {sql} from './query/sql.js'; 7 | export {Q} from './qustar.js'; 8 | export {renderMysql} from './render/mysql.js'; 9 | export {renderPostgresql} from './render/postgresql.js'; 10 | export {renderSqlite} from './render/sqlite.js'; 11 | export {optimize} from './sql/optimizer.js'; 12 | export { 13 | AliasSql, 14 | BinarySql, 15 | BinarySqlOp, 16 | CaseSql, 17 | CaseSqlWhen, 18 | CombinationSql, 19 | ExprSql, 20 | FuncSql, 21 | GenericBinarySql, 22 | GenericFuncSql, 23 | GenericSqlSource as GenericSelectSqlFrom, 24 | GenericSql, 25 | GenericUnarySql, 26 | LiteralSql, 27 | LookupSql, 28 | QuerySql, 29 | RawSql, 30 | RowNumberSql, 31 | SelectSql, 32 | SelectSqlColumn, 33 | QuerySqlSource as SelectSqlFromQuery, 34 | RawSqlSource as SelectSqlFromSql, 35 | TableSqlSource as SelectSqlFromTable, 36 | SelectSqlJoin, 37 | SqlCombinationType, 38 | SqlOrderBy, 39 | SqlSource, 40 | UnarySql, 41 | UnarySqlOp, 42 | } from './sql/sql.js'; 43 | export { 44 | FilterFn, 45 | JoinFilterFn, 46 | MapQueryFn, 47 | MapScalarArrayFn, 48 | MapScalarFn, 49 | MapValueFn, 50 | Mapping, 51 | ScalarMapping, 52 | } from './types/query.js'; 53 | 54 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 55 | export function interpretQuery(...args: any[]): any { 56 | throw new Error('todo: implement interpretQuery'); 57 | } 58 | -------------------------------------------------------------------------------- /packages/qustar/src/literal.ts: -------------------------------------------------------------------------------- 1 | import {assertNever} from './utils.js'; 2 | 3 | export interface GenericScalarType { 4 | readonly type: TType; 5 | readonly nullable: boolean; 6 | } 7 | 8 | export interface GenericArrayScalarType 9 | extends GenericScalarType<'array'> { 10 | readonly itemType: TScalarType; 11 | } 12 | 13 | export type SingleScalarType = InferScalarType; 14 | export type ArrayScalarType = InferScalarType; 15 | export type ScalarType = SingleScalarType | ArrayScalarType; 16 | 17 | type InferScalarType> = 18 | TType extends GenericLiteral ? TScalarType : never; 19 | 20 | export interface GenericLiteral< 21 | TType extends GenericScalarType, 22 | TValue, 23 | > { 24 | readonly type: TType; 25 | readonly value: TValue; 26 | } 27 | 28 | export type SingleLiteralValue = InferLiteralValue; 29 | export type ArrayLiteralValue = InferLiteralValue; 30 | export type LiteralValue = SingleLiteralValue | ArrayLiteralValue; 31 | 32 | export interface BooleanScalarType extends GenericScalarType<'boolean'> {} 33 | export interface NullScalarType extends GenericScalarType<'null'> {} 34 | export interface StringScalarType extends GenericScalarType<'string'> {} 35 | export interface Int8ScalarType extends GenericScalarType<'i8'> {} 36 | export interface Int16ScalarType extends GenericScalarType<'i16'> {} 37 | export interface Int32ScalarType extends GenericScalarType<'i32'> {} 38 | export interface Int64ScalarType extends GenericScalarType<'i64'> {} 39 | export interface Float32ScalarType extends GenericScalarType<'f32'> {} 40 | export interface Float64ScalarType extends GenericScalarType<'f64'> {} 41 | 42 | export type NumericScalarType = 43 | | Int8ScalarType 44 | | Int16ScalarType 45 | | Int32ScalarType 46 | | Int64ScalarType 47 | | Float32ScalarType 48 | | Float64ScalarType; 49 | 50 | export interface BooleanLiteral 51 | extends GenericLiteral {} 52 | export interface NullLiteral extends GenericLiteral {} 53 | export interface I8Literal extends GenericLiteral {} 54 | export interface I16Literal extends GenericLiteral {} 55 | export interface I32Literal extends GenericLiteral {} 56 | export interface I64Literal extends GenericLiteral {} 57 | export interface F32Literal extends GenericLiteral {} 58 | export interface F64Literal extends GenericLiteral {} 59 | export interface StringLiteral 60 | extends GenericLiteral {} 61 | 62 | export type SingleLiteral = 63 | | BooleanLiteral 64 | | NullLiteral 65 | | I8Literal 66 | | I16Literal 67 | | I32Literal 68 | | I64Literal 69 | | F32Literal 70 | | F64Literal 71 | | StringLiteral; 72 | 73 | export type ArrayLiteral = InferArrayLiteral; 74 | 75 | export type Literal = SingleLiteral | ArrayLiteral; 76 | 77 | // infer 78 | 79 | type InferLiteralValue> = 80 | T extends GenericLiteral ? TValue : never; 81 | 82 | type InferArrayLiteral> = 83 | T extends GenericLiteral 84 | ? TScalarType extends SingleScalarType 85 | ? GenericLiteral, TLiteralValue[]> 86 | : never 87 | : never; 88 | 89 | export function assertArrayLiteral( 90 | literal: Literal 91 | ): asserts literal is ArrayLiteral { 92 | if (literal.type.type !== 'array') { 93 | throw new Error('literal is not an array: ' + literal.type.type); 94 | } 95 | } 96 | 97 | export function assertSingleLiteral( 98 | literal: Literal 99 | ): asserts literal is SingleLiteral { 100 | if (literal.type.type === 'array') { 101 | throw new Error('literal is an array'); 102 | } 103 | } 104 | 105 | export function inferSingleLiteral(value: SingleLiteralValue): SingleLiteral { 106 | // todo: add date support 107 | 108 | if (typeof value === 'string') { 109 | return { 110 | type: {type: 'string', nullable: false}, 111 | value, 112 | }; 113 | } 114 | 115 | if (typeof value === 'number') { 116 | if (Number.isSafeInteger(value)) { 117 | return { 118 | type: {type: 'i64', nullable: false}, 119 | value, 120 | }; 121 | } else if (Number.isFinite(value)) { 122 | return { 123 | type: {type: 'f64', nullable: false}, 124 | value, 125 | }; 126 | } else if (Number.isNaN(value)) { 127 | throw new Error('NaN is not supported'); 128 | } else if (Number.isInteger(value)) { 129 | throw new Error('unsafe integer is not supported'); 130 | } else { 131 | throw new Error('unsupported number: ' + value); 132 | } 133 | } 134 | 135 | if (typeof value === 'boolean') { 136 | return { 137 | type: {type: 'boolean', nullable: false}, 138 | value, 139 | }; 140 | } 141 | 142 | if (value === null) { 143 | return { 144 | type: {type: 'null', nullable: true}, 145 | value, 146 | }; 147 | } 148 | 149 | return assertNever(value, 'unsupported type of the value: ' + typeof value); 150 | } 151 | 152 | export function inferLiteral(value: LiteralValue): Literal { 153 | if ( 154 | typeof value === 'string' || 155 | typeof value === 'boolean' || 156 | typeof value === 'number' || 157 | value === null 158 | ) { 159 | return inferSingleLiteral(value); 160 | } 161 | 162 | if (Array.isArray(value)) { 163 | const itemTypes = value.map(inferLiteral); 164 | 165 | if (itemTypes.length === 0) { 166 | throw new Error('empty literal arrays are not supported'); 167 | } 168 | 169 | if (new Set(itemTypes.map(x => x.type.type)).size > 1) { 170 | throw new Error('array literals with mixed item type are not supported'); 171 | } 172 | 173 | // all items are of the same type and at least one element in array (checks above) 174 | const firstItemType = itemTypes[0].type; 175 | 176 | if (firstItemType.type === 'array') { 177 | throw new Error('nested array are not supported in literal types'); 178 | } 179 | 180 | const itemType: SingleScalarType = { 181 | ...firstItemType, 182 | nullable: itemTypes.some(x => x.type.nullable), 183 | }; 184 | const arrayScalarType: ArrayScalarType = { 185 | type: 'array', 186 | nullable: false, 187 | itemType: itemType, 188 | } as any; 189 | 190 | const literal: ArrayLiteral = { 191 | type: arrayScalarType, 192 | value: value, 193 | } as ArrayLiteral; 194 | 195 | return literal; 196 | } 197 | 198 | return assertNever(value, 'unsupported type of the value: ' + typeof value); 199 | } 200 | 201 | export function isPrimitive(value: unknown) { 202 | return ( 203 | typeof value === 'string' || 204 | typeof value === 'number' || 205 | typeof value === 'boolean' || 206 | value instanceof Date || 207 | value === null 208 | ); 209 | } 210 | 211 | export function isObject(value: unknown) { 212 | return typeof value === 'object' && !isPrimitive(value); 213 | } 214 | -------------------------------------------------------------------------------- /packages/qustar/src/query/projection.ts: -------------------------------------------------------------------------------- 1 | import {ScalarType, SingleScalarType} from '../literal.js'; 2 | import {Expr} from './expr.js'; 3 | import {Ref} from './schema.js'; 4 | 5 | export interface ProjectionVisitor { 6 | scalar(proj: ScalarProjection): T; 7 | object(proj: ObjectProjection): T; 8 | } 9 | 10 | export abstract class Projection { 11 | constructor(public readonly nullable: boolean) {} 12 | 13 | abstract visit(visitor: ProjectionVisitor): T; 14 | } 15 | 16 | export type PropPath = string[]; 17 | 18 | export interface ScalarProjectionOptions { 19 | readonly scalarType: ScalarType; 20 | readonly expr: Expr; 21 | } 22 | 23 | export class ScalarProjection extends Projection { 24 | public readonly scalarType: ScalarType; 25 | public readonly expr: Expr; 26 | 27 | constructor(options: ScalarProjectionOptions) { 28 | super(options.scalarType.nullable); 29 | 30 | this.scalarType = options.scalarType; 31 | this.expr = options.expr; 32 | } 33 | 34 | visit(visitor: ProjectionVisitor): T { 35 | return visitor.scalar(this); 36 | } 37 | } 38 | 39 | export interface PropProjection { 40 | readonly expr: Expr; 41 | readonly path: PropPath; 42 | readonly scalarType: SingleScalarType; 43 | } 44 | 45 | export interface ObjectProjectionOptions { 46 | readonly props: readonly PropProjection[]; 47 | readonly refs: readonly Ref[]; 48 | readonly nullable: boolean; 49 | } 50 | 51 | export class ObjectProjection extends Projection { 52 | public readonly props: readonly PropProjection[]; 53 | public readonly refs: readonly Ref[]; 54 | 55 | constructor(options: ObjectProjectionOptions) { 56 | super(options.nullable); 57 | 58 | this.props = options.props; 59 | this.refs = options.refs; 60 | } 61 | 62 | visit(visitor: ProjectionVisitor): T { 63 | return visitor.object(this); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/qustar/src/query/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayLiteralValue, 3 | SingleLiteralValue, 4 | SingleScalarType, 5 | } from '../literal.js'; 6 | import {JoinFilterFn} from '../types/query.js'; 7 | import {SingleScalarOperand} from './expr.js'; 8 | import {PropPath} from './projection.js'; 9 | import {Query} from './query.js'; 10 | 11 | export interface Field { 12 | readonly scalarType: SingleScalarType; 13 | readonly name: string; 14 | readonly isGenerated: boolean; 15 | } 16 | 17 | export interface Schema { 18 | readonly fields: readonly Field[]; 19 | readonly refs: readonly Ref[]; 20 | } 21 | 22 | export interface SqlTemplate { 23 | readonly src: TemplateStringsArray; 24 | readonly args: Array< 25 | SingleScalarOperand | ArrayLiteralValue 26 | >; 27 | } 28 | 29 | export namespace SqlTemplate { 30 | export function derive(sql: SqlTemplate | string): SqlTemplate { 31 | if (typeof sql === 'string') { 32 | return { 33 | src: Object.freeze({ 34 | ...[sql], 35 | raw: [sql], 36 | }), 37 | args: [], 38 | }; 39 | } else { 40 | return sql; 41 | } 42 | } 43 | } 44 | 45 | export interface GenericRef { 46 | readonly type: TType; 47 | // ref is always defined at root (so path length always 48 | // equals to one) but when using wildcard projections it 49 | // can migrate to a nested object. In that case condition 50 | // should be called with nested object as a handle 51 | readonly path: PropPath; 52 | 53 | readonly child: () => Query; 54 | readonly parent: () => Query; 55 | 56 | readonly condition: JoinFilterFn; 57 | } 58 | 59 | export interface ForwardRef extends GenericRef<'forward_ref'> { 60 | readonly nullable: boolean; 61 | } 62 | 63 | export interface BackRef extends GenericRef<'back_ref'> { 64 | readonly nullable: false; 65 | } 66 | 67 | export type Ref = ForwardRef | BackRef; 68 | -------------------------------------------------------------------------------- /packages/qustar/src/query/sql.ts: -------------------------------------------------------------------------------- 1 | import {ArrayLiteralValue, SingleLiteralValue} from '../literal.js'; 2 | import {SingleScalarOperand} from './expr.js'; 3 | import {SqlTemplate} from './schema.js'; 4 | 5 | export function sql( 6 | src: TemplateStringsArray, 7 | ...args: Array | ArrayLiteralValue> 8 | ): SqlTemplate { 9 | return {src, args}; 10 | } 11 | -------------------------------------------------------------------------------- /packages/qustar/src/qustar.ts: -------------------------------------------------------------------------------- 1 | import {Prop as OriginalProp, Prop} from './descriptor'; 2 | import {SingleLiteralValue} from './literal'; 3 | import {Expr, Expr as OriginalExpr} from './query/expr'; 4 | import {Query as OriginalQuery, Query} from './query/query'; 5 | import {ValidValue} from './types/query'; 6 | 7 | type StaticMethods = { 8 | [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : never; 9 | }; 10 | 11 | type QueryStaticMethods = StaticMethods; 12 | type ExprStaticMethods = StaticMethods; 13 | type PropStaticMethods = StaticMethods; 14 | 15 | export type Qustar = QueryStaticMethods & 16 | PropStaticMethods & 17 | ExprStaticMethods & { 18 | Query: typeof Query; 19 | Expr: typeof Expr; 20 | Prop: typeof Prop; 21 | }; 22 | 23 | export const Q: Qustar = combineObjects(Query, Expr, Prop, { 24 | Query: Query, 25 | Expr: Expr, 26 | Prop: Prop, 27 | }); 28 | 29 | export const Qustar = Q; 30 | 31 | export namespace Q { 32 | export type Schema = Query.Schema; 33 | export type Infer> = Query.Infer; 34 | export type Query> = OriginalQuery; 35 | export type Expr = OriginalExpr; 36 | export type Prop< 37 | TType, 38 | TIsGenerated extends boolean, 39 | TIsRef extends boolean, 40 | > = OriginalProp; 41 | } 42 | 43 | function combineObjects(...sources: any[]) { 44 | const target: any = {}; 45 | sources.forEach(source => { 46 | Object.getOwnPropertyNames(source).forEach(name => { 47 | if (typeof source[name] === 'function') { 48 | target[name] = source[name]; 49 | } 50 | }); 51 | }); 52 | 53 | return target; 54 | } 55 | -------------------------------------------------------------------------------- /packages/qustar/src/render/mysql.ts: -------------------------------------------------------------------------------- 1 | import {SqlCommand} from '../connector.js'; 2 | import {Sql} from '../sql/sql.js'; 3 | import {renderSql} from './sql.js'; 4 | 5 | export function renderMysql(sql: Sql): SqlCommand { 6 | return renderSql(sql, { 7 | float32Type: 'FLOAT', 8 | int32Type: 'SIGNED', 9 | textType: 'CHAR', 10 | xor: '^', 11 | castToIntAfterBitwiseNot: true, 12 | emulateArrayLiteralParam: true, 13 | escapeId(id: string): string { 14 | return '`' + id.split('`').join('``') + '`'; 15 | }, 16 | placeholder: () => '?', 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /packages/qustar/src/render/postgresql.ts: -------------------------------------------------------------------------------- 1 | import {match} from 'ts-pattern'; 2 | import {SqlCommand} from '../connector.js'; 3 | import {ScalarType} from '../literal.js'; 4 | import {Sql} from '../sql/sql.js'; 5 | import {renderSql} from './sql.js'; 6 | 7 | export function renderPostgresql(sql: Sql): SqlCommand { 8 | return renderSql(sql, { 9 | float32Type: 'REAL', 10 | int32Type: 'INT', 11 | textType: 'TEXT', 12 | xor: '#', 13 | emulateArrayLiteralParam: true, 14 | escapeId(id: string): string { 15 | return `"${id.split('"').join('""')}"`; 16 | }, 17 | placeholder: (idx, literal) => { 18 | const type = toPostgreSqlType(literal.type); 19 | if (type) { 20 | return `$${idx + 1}::${type}`; 21 | } else { 22 | return `$${idx + 1}`; 23 | } 24 | }, 25 | }); 26 | } 27 | 28 | function toPostgreSqlType(type: ScalarType): string | undefined { 29 | return match(type) 30 | .with({type: 'i8'}, () => 'smallint') 31 | .with({type: 'i16'}, () => 'smallint') 32 | .with({type: 'i32'}, () => 'integer') 33 | .with({type: 'i64'}, () => 'bigint') 34 | .with({type: 'boolean'}, () => 'boolean') 35 | .with({type: 'array'}, ({itemType}) => { 36 | const type = toPostgreSqlType(itemType); 37 | if (type) { 38 | return type + '[]'; 39 | } else { 40 | return undefined; 41 | } 42 | }) 43 | .with({type: 'f32'}, () => 'real') 44 | .with({type: 'f64'}, () => 'double precision') 45 | .with({type: 'string'}, () => 'string') 46 | .with({type: 'null'}, () => undefined) 47 | .exhaustive(); 48 | } 49 | -------------------------------------------------------------------------------- /packages/qustar/src/render/sqlite.ts: -------------------------------------------------------------------------------- 1 | import {SqlCommand} from '../connector.js'; 2 | import {Sql} from '../sql/sql.js'; 3 | import {renderSql} from './sql.js'; 4 | 5 | export function renderSqlite(sql: Sql): SqlCommand { 6 | return renderSql(sql, { 7 | float32Type: 'REAL', 8 | int32Type: 'INT', 9 | textType: 'TEXT', 10 | xor: '^', 11 | emulateBoolean: true, 12 | emulateArrayLiteralParam: true, 13 | emulateXor: true, 14 | escapeId(id: string): string { 15 | return `"${id.split('"').join('""')}"`; 16 | }, 17 | placeholder: () => '?', 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /packages/qustar/src/sql/mapper.ts: -------------------------------------------------------------------------------- 1 | import {match} from 'ts-pattern'; 2 | import { 3 | AliasSql, 4 | BinarySql, 5 | CaseSql, 6 | CombinationSql, 7 | ExprSql, 8 | FuncSql, 9 | LiteralSql, 10 | LookupSql, 11 | QuerySql, 12 | RawSql, 13 | RowNumberSql, 14 | SelectSql, 15 | SelectSqlColumn, 16 | SelectSqlJoin, 17 | SqlSource, 18 | UnarySql, 19 | } from './sql.js'; 20 | 21 | function idMapper(x: T): T { 22 | return x; 23 | } 24 | 25 | export const ID_SQL_MAPPER: SqlMapper = { 26 | alias: idMapper, 27 | binary: idMapper, 28 | case: idMapper, 29 | combination: idMapper, 30 | func: idMapper, 31 | literal: idMapper, 32 | lookup: idMapper, 33 | select: idMapper, 34 | unary: idMapper, 35 | source: idMapper, 36 | column: idMapper, 37 | join: idMapper, 38 | raw: idMapper, 39 | rowNumber: idMapper, 40 | }; 41 | 42 | interface QuerySqlMapper { 43 | combination: (sql: CombinationSql) => QuerySql; 44 | select: (sql: SelectSql) => SelectSql; 45 | } 46 | 47 | interface SqlMapper extends QuerySqlMapper { 48 | func: (sql: FuncSql) => ExprSql; 49 | alias: (sql: AliasSql) => AliasSql; 50 | binary: (sql: BinarySql) => ExprSql; 51 | case: (sql: CaseSql) => ExprSql; 52 | literal: (sql: LiteralSql) => ExprSql; 53 | lookup: (sql: LookupSql) => ExprSql; 54 | unary: (sql: UnarySql) => ExprSql; 55 | raw: (sql: RawSql) => ExprSql; 56 | rowNumber: (sql: RowNumberSql) => ExprSql; 57 | source: (sql: SqlSource) => SqlSource; 58 | column: ( 59 | sql: SelectSqlColumn 60 | ) => SelectSqlColumn | readonly SelectSqlColumn[]; 61 | join: (sql: SelectSqlJoin) => SelectSqlJoin | readonly SelectSqlJoin[]; 62 | } 63 | 64 | export function mapQuery(sql: QuerySql, mapper: SqlMapper): QuerySql { 65 | return match(sql) 66 | .with({type: 'combination'}, x => mapCombination(x, mapper)) 67 | .with({type: 'select'}, x => mapSelect(x, mapper)) 68 | .exhaustive(); 69 | } 70 | 71 | export function mapSql(sql: ExprSql, mapper: SqlMapper): ExprSql { 72 | return match(sql) 73 | .with({type: 'func'}, x => mapFunc(x, mapper)) 74 | .with({type: 'alias'}, x => mapAlias(x, mapper)) 75 | .with({type: 'binary'}, x => mapBinary(x, mapper)) 76 | .with({type: 'case'}, x => mapCase(x, mapper)) 77 | .with({type: 'combination'}, x => mapCombination(x, mapper)) 78 | .with({type: 'select'}, x => mapSelect(x, mapper)) 79 | .with({type: 'literal'}, x => mapLiteral(x, mapper)) 80 | .with({type: 'lookup'}, x => mapLookup(x, mapper)) 81 | .with({type: 'unary'}, x => mapUnary(x, mapper)) 82 | .with({type: 'raw'}, x => mapRaw(x, mapper)) 83 | .with({type: 'row_number'}, x => mapRowNumber(x, mapper)) 84 | .exhaustive(); 85 | } 86 | 87 | function mapFunc(sql: FuncSql, mapper: SqlMapper): ExprSql { 88 | return mapper.func({ 89 | type: sql.type, 90 | args: sql.args.map(x => mapSql(x, mapper)), 91 | func: sql.func, 92 | }); 93 | } 94 | 95 | function mapAlias(sql: AliasSql, mapper: SqlMapper): ExprSql { 96 | return mapper.alias(sql); 97 | } 98 | 99 | function mapBinary(sql: BinarySql, mapper: SqlMapper): ExprSql { 100 | return mapper.binary({ 101 | type: sql.type, 102 | op: sql.op, 103 | lhs: mapSql(sql.lhs, mapper), 104 | rhs: mapSql(sql.rhs, mapper), 105 | }); 106 | } 107 | 108 | function mapCase(sql: CaseSql, mapper: SqlMapper): ExprSql { 109 | return mapper.case({ 110 | type: sql.type, 111 | subject: mapSql(sql.subject, mapper), 112 | whens: sql.whens.map(x => ({ 113 | condition: mapSql(x.condition, mapper), 114 | result: mapSql(x.result, mapper), 115 | })), 116 | fallback: mapSql(sql.fallback, mapper), 117 | }); 118 | } 119 | 120 | function mapCombination(sql: CombinationSql, mapper: SqlMapper): QuerySql { 121 | return mapper.combination({ 122 | type: sql.type, 123 | combType: sql.combType, 124 | lhs: mapQuery(sql.lhs, mapper), 125 | rhs: mapQuery(sql.rhs, mapper), 126 | }); 127 | } 128 | 129 | function mapLiteral(sql: LiteralSql, mapper: SqlMapper): ExprSql { 130 | return mapper.literal(sql); 131 | } 132 | 133 | function mapLookup(sql: LookupSql, mapper: SqlMapper): ExprSql { 134 | return mapper.lookup({ 135 | type: sql.type, 136 | prop: sql.prop, 137 | subject: mapSql(sql.subject, mapper), 138 | }); 139 | } 140 | 141 | function mapSqlSource(source: SqlSource, mapper: SqlMapper): SqlSource { 142 | return mapper.source( 143 | match(source) 144 | .with({type: 'table'}, x => x) 145 | .with( 146 | {type: 'query'}, 147 | (x): SqlSource => ({ 148 | type: x.type, 149 | as: x.as, 150 | query: mapQuery(x.query, mapper), 151 | }) 152 | ) 153 | .with( 154 | {type: 'sql'}, 155 | (source): SqlSource => ({ 156 | type: source.type, 157 | as: source.as, 158 | sql: { 159 | type: source.sql.type, 160 | src: source.sql.src, 161 | args: source.sql.args.map(arg => mapSql(arg, mapper)), 162 | }, 163 | }) 164 | ) 165 | .exhaustive() 166 | ); 167 | } 168 | 169 | export function mapSelect(sql: SelectSql, mapper: SqlMapper): SelectSql { 170 | return mapper.select({ 171 | type: sql.type, 172 | columns: sql.columns 173 | .map(column => ({ 174 | as: column.as, 175 | expr: mapSql(column.expr, mapper), 176 | })) 177 | .flatMap(mapper.column), 178 | from: sql.from ? mapSqlSource(sql.from, mapper) : undefined, 179 | joins: sql.joins 180 | .map(join => ({ 181 | type: join.type, 182 | condition: join.condition && mapSql(join.condition, mapper), 183 | right: mapSqlSource(join.right, mapper), 184 | lateral: join.lateral, 185 | })) 186 | .flatMap(mapper.join), 187 | distinct: sql.distinct, 188 | groupBy: 189 | sql.groupBy === undefined 190 | ? undefined 191 | : sql.groupBy.map(x => mapSql(x, mapper)), 192 | having: sql.having === undefined ? undefined : mapSql(sql.having, mapper), 193 | where: sql.where === undefined ? undefined : mapSql(sql.where, mapper), 194 | orderBy: 195 | sql.orderBy === undefined 196 | ? undefined 197 | : sql.orderBy.map(x => ({ 198 | expr: mapSql(x.expr, mapper), 199 | type: x.type, 200 | })), 201 | limit: sql.limit, 202 | offset: sql.offset, 203 | }); 204 | } 205 | 206 | function mapUnary(sql: UnarySql, mapper: SqlMapper): ExprSql { 207 | return mapper.unary({ 208 | type: sql.type, 209 | op: sql.op, 210 | inner: mapSql(sql.inner, mapper), 211 | }); 212 | } 213 | 214 | function mapRaw(sql: RawSql, mapper: SqlMapper): ExprSql { 215 | return mapper.raw({ 216 | type: sql.type, 217 | src: sql.src, 218 | args: sql.args.map(x => mapSql(x, mapper)), 219 | }); 220 | } 221 | 222 | function mapRowNumber(sql: RowNumberSql, mapper: SqlMapper): ExprSql { 223 | return mapper.rowNumber({ 224 | type: sql.type, 225 | orderBy: sql.orderBy 226 | ? sql.orderBy.map(x => ({ 227 | type: x.type, 228 | expr: mapSql(x.expr, mapper), 229 | })) 230 | : undefined, 231 | }); 232 | } 233 | -------------------------------------------------------------------------------- /packages/qustar/src/sql/sql.ts: -------------------------------------------------------------------------------- 1 | import {Literal} from '../literal.js'; 2 | import {JoinType, OrderByType} from '../query/query.js'; 3 | 4 | export type QuerySql = SelectSql | CombinationSql; 5 | 6 | export type ExprSql = 7 | | AliasSql 8 | | BinarySql 9 | | CaseSql 10 | | CombinationSql 11 | | LiteralSql 12 | | LookupSql 13 | | FuncSql 14 | | SelectSql 15 | | UnarySql 16 | | RawSql 17 | | RowNumberSql; 18 | 19 | export type StmtSql = InsertSql | DeleteSql | UpdateSql; 20 | 21 | export type Sql = ExprSql | StmtSql; 22 | 23 | export interface GenericSql { 24 | readonly type: TType; 25 | } 26 | 27 | // === row number === 28 | 29 | export interface RowNumberSql extends GenericSql<'row_number'> { 30 | readonly orderBy: readonly SqlOrderBy[] | undefined; 31 | } 32 | 33 | // === func === 34 | 35 | export type FuncSql = GenericFuncSql< 36 | | 'lower' 37 | | 'upper' 38 | | 'substring' 39 | | 'concat' 40 | | 'max' 41 | | 'min' 42 | | 'avg' 43 | | 'count' 44 | | 'sum' 45 | | 'to_string' 46 | | 'to_int32' 47 | | 'to_float32' 48 | | 'coalesce' 49 | | 'length' 50 | >; 51 | 52 | export interface GenericFuncSql 53 | extends GenericSql<'func'> { 54 | readonly func: TFunc; 55 | readonly args: readonly ExprSql[]; 56 | } 57 | 58 | // === literal === 59 | 60 | export interface LiteralSql extends GenericSql<'literal'> { 61 | readonly literal: Literal; 62 | readonly parameter: boolean; 63 | } 64 | 65 | // === unary === 66 | 67 | export type UnarySqlOp = 68 | | '!' 69 | | '+' 70 | | '-' 71 | | '~' 72 | | 'exists' 73 | | 'not_exists' 74 | | 'is_null' 75 | | 'is_not_null'; 76 | 77 | export type UnarySql = GenericUnarySql; 78 | 79 | export interface GenericUnarySql 80 | extends GenericSql<'unary'> { 81 | readonly op: TVariant; 82 | readonly inner: ExprSql; 83 | } 84 | 85 | // === binary === 86 | 87 | export type BinarySqlOp = 88 | | '+' 89 | | '-' 90 | | '*' 91 | | '/' 92 | | '%' 93 | | '<<' 94 | | '>>' 95 | | '&' 96 | | '|' 97 | | '^' 98 | | 'or' 99 | | 'and' 100 | | '>' 101 | | '>=' 102 | | '<' 103 | | '<=' 104 | | '==' 105 | | '!=' 106 | | 'like' 107 | | 'in'; 108 | 109 | export type BinarySql = GenericBinarySql; 110 | 111 | export interface GenericBinarySql 112 | extends GenericSql<'binary'> { 113 | readonly op: TVariant; 114 | readonly lhs: ExprSql; 115 | readonly rhs: ExprSql; 116 | } 117 | 118 | // === case === 119 | 120 | export interface CaseSql extends GenericSql<'case'> { 121 | readonly subject: ExprSql; 122 | readonly whens: readonly CaseSqlWhen[]; 123 | readonly fallback: ExprSql; 124 | } 125 | 126 | export interface CaseSqlWhen { 127 | readonly condition: ExprSql; 128 | readonly result: ExprSql; 129 | } 130 | 131 | // === unary === 132 | 133 | export interface AliasSql extends GenericSql<'alias'> { 134 | readonly name: string; 135 | } 136 | 137 | // === path === 138 | 139 | export interface LookupSql extends GenericSql<'lookup'> { 140 | readonly subject: ExprSql; 141 | readonly prop: string; 142 | } 143 | 144 | // === raw === 145 | 146 | export interface RawSql extends GenericSql<'raw'> { 147 | readonly src: TemplateStringsArray; 148 | readonly args: ExprSql[]; 149 | } 150 | 151 | // === insert === 152 | 153 | export interface InsertSql extends GenericSql<'insert'> { 154 | readonly table: string; 155 | readonly columns: string[]; 156 | readonly rows: LiteralSql[][]; 157 | } 158 | 159 | // === delete === 160 | 161 | export interface DeleteSql extends GenericSql<'delete'> { 162 | readonly table: TableSqlSource; 163 | readonly where: ExprSql; 164 | } 165 | 166 | // === update === 167 | 168 | export interface SqlSet { 169 | readonly column: string; 170 | readonly value: ExprSql; 171 | } 172 | 173 | export interface UpdateSql extends GenericSql<'update'> { 174 | readonly table: TableSqlSource; 175 | readonly set: SqlSet[]; 176 | readonly where: ExprSql; 177 | } 178 | 179 | // === select === 180 | 181 | export const EMPTY_SELECT: SelectSql = { 182 | columns: [], 183 | distinct: undefined, 184 | from: undefined, 185 | groupBy: undefined, 186 | having: undefined, 187 | joins: [], 188 | limit: undefined, 189 | offset: undefined, 190 | orderBy: undefined, 191 | type: 'select', 192 | where: undefined, 193 | }; 194 | 195 | export interface SelectSql extends GenericSql<'select'> { 196 | readonly distinct: true | undefined; 197 | readonly columns: readonly SelectSqlColumn[]; 198 | readonly from: SqlSource | undefined; 199 | readonly joins: readonly SelectSqlJoin[]; 200 | readonly where: ExprSql | undefined; 201 | readonly groupBy: readonly ExprSql[] | undefined; 202 | readonly having: ExprSql | undefined; 203 | readonly orderBy: readonly SqlOrderBy[] | undefined; 204 | readonly limit: number | undefined; 205 | readonly offset: number | undefined; 206 | } 207 | 208 | // select select 209 | 210 | export interface SelectSqlColumn { 211 | readonly expr: ExprSql; 212 | readonly as: string; 213 | } 214 | 215 | // select from 216 | 217 | export interface GenericSqlSource { 218 | readonly type: TType; 219 | readonly as: string; 220 | } 221 | 222 | export interface QuerySqlSource extends GenericSqlSource<'query'> { 223 | readonly query: QuerySql; 224 | } 225 | 226 | export interface TableSqlSource extends GenericSqlSource<'table'> { 227 | readonly table: string; 228 | } 229 | 230 | export interface RawSqlSource extends GenericSqlSource<'sql'> { 231 | readonly sql: RawSql; 232 | } 233 | 234 | export type SqlSource = TableSqlSource | QuerySqlSource | RawSqlSource; 235 | 236 | // select join 237 | 238 | export interface SelectSqlJoin { 239 | readonly right: SqlSource; 240 | readonly condition: ExprSql; 241 | readonly type: JoinType; 242 | readonly lateral: boolean; 243 | } 244 | 245 | // order by 246 | 247 | export interface SqlOrderBy { 248 | readonly type: OrderByType; 249 | readonly expr: ExprSql; 250 | } 251 | 252 | // === combination === 253 | 254 | export type SqlCombinationType = 'union' | 'union_all' | 'intersect' | 'except'; 255 | 256 | export interface CombinationSql extends GenericSql<'combination'> { 257 | readonly combType: SqlCombinationType; 258 | readonly lhs: QuerySql; 259 | readonly rhs: QuerySql; 260 | } 261 | 262 | // literals 263 | 264 | export const nullLiteral: LiteralSql = { 265 | type: 'literal', 266 | parameter: false, 267 | literal: { 268 | type: {type: 'null', nullable: true}, 269 | value: null, 270 | }, 271 | }; 272 | 273 | export const oneLiteral: LiteralSql = { 274 | type: 'literal', 275 | parameter: false, 276 | literal: { 277 | type: {type: 'i32', nullable: false}, 278 | value: 1, 279 | }, 280 | }; 281 | 282 | export const constStrLiteral: LiteralSql = { 283 | type: 'literal', 284 | parameter: false, 285 | literal: { 286 | type: {type: 'string', nullable: false}, 287 | value: 'const', 288 | }, 289 | }; 290 | 291 | export const trueLiteral: LiteralSql = { 292 | type: 'literal', 293 | parameter: false, 294 | literal: { 295 | type: {type: 'boolean', nullable: false}, 296 | value: true, 297 | }, 298 | }; 299 | 300 | export const falseLiteral: LiteralSql = { 301 | type: 'literal', 302 | parameter: false, 303 | literal: { 304 | type: {type: 'boolean', nullable: false}, 305 | value: false, 306 | }, 307 | }; 308 | 309 | export const zeroLiteral: LiteralSql = { 310 | type: 'literal', 311 | parameter: false, 312 | literal: { 313 | type: {type: 'i32', nullable: false}, 314 | value: 0, 315 | }, 316 | }; 317 | -------------------------------------------------------------------------------- /packages/qustar/src/types/query.ts: -------------------------------------------------------------------------------- 1 | import {SingleLiteralValue} from '../literal.js'; 2 | import {Expr, SingleScalarOperand} from '../query/expr.js'; 3 | import {Query} from '../query/query.js'; 4 | 5 | export type IsAny = 0 extends 1 & a ? true : false; 6 | 7 | export type Assert = T; 8 | export type Not = [T] extends [true] ? false : true; 9 | export type Equal = [T1] extends [T2] 10 | ? [T2] extends [T1] 11 | ? true 12 | : false 13 | : false; 14 | export type NotEqual = Not>; 15 | type __TestEqual = Assert< 16 | [ 17 | Equal<1, 1>, 18 | NotEqual, 19 | NotEqual<1, number>, 20 | Equal<{a: string}, {a: string}>, 21 | ] 22 | >; 23 | 24 | export type Never = [T] extends [never] ? true : false; 25 | export type Ever = Not>; 26 | type __TestEver = Assert<[Equal, true>, Equal, false>]>; 27 | 28 | export type ArrayItemType = [T] extends [ 29 | ReadonlyArray, 30 | ] 31 | ? I 32 | : never; 33 | 34 | export type ScalarHandle = Expr; 35 | type __TestScalarHandle = Assert< 36 | Equal, ScalarHandle> 37 | >; 38 | 39 | type ValidScalar = Extract; 40 | type ValidNavProperty = ValidEntity | Array>; 41 | type ValidEntity = { 42 | [K in keyof T]: ValidScalar | ValidNavProperty; 43 | }; 44 | 45 | export type EntityHandle = { 46 | [K in keyof Exclude]: EntityPropertyHandle< 47 | Exclude[K] | Extract 48 | >; 49 | }; 50 | export type EntityPropertyHandle< 51 | T, 52 | TValue = Exclude, 53 | TNull extends null = Extract, 54 | > = [TValue] extends [SingleLiteralValue] 55 | ? ScalarHandle 56 | : [TValue] extends [ReadonlyArray] 57 | ? Query | TNull> 58 | : [TValue] extends [ValidEntity] 59 | ? EntityHandle 60 | : never; 61 | 62 | type __TestEntityHandle = Assert< 63 | Equal< 64 | {a: ScalarHandle; b: ScalarHandle}, 65 | EntityHandle<{a: number; b: string}> 66 | > 67 | >; 68 | 69 | type Any = 0 extends 1 & T ? true : false; 70 | 71 | export type ValidValue = SingleLiteralValue | ValidEntity; 72 | export type Handle> = 73 | Any extends true 74 | ? any 75 | : [T] extends [SingleLiteralValue] 76 | ? ScalarHandle 77 | : [T] extends [ValidEntity] 78 | ? EntityHandle 79 | : never; 80 | type __TestHandle = Assert< 81 | [ 82 | Equal, Handle>, 83 | Equal<{a: ScalarHandle}, Handle<{a: boolean}>>, 84 | ] 85 | >; 86 | 87 | export type EntityMapping = Record; 88 | export type ScalarMapping = SingleScalarOperand; 89 | export type NumericMapping = SingleScalarOperand; 90 | export type Mapping = ScalarMapping | EntityMapping | EntityHandle; 91 | 92 | export type MapValueFn< 93 | Input extends ValidValue, 94 | Result extends Mapping, 95 | > = (x: Handle) => Result; 96 | export type MapScalarFn< 97 | Input extends ValidValue, 98 | Result extends ScalarMapping, 99 | > = (x: Handle) => Result; 100 | export type MapScalarArrayFn< 101 | Input extends ValidValue, 102 | Result extends readonly ScalarMapping[] | ScalarMapping, 103 | > = (x: Handle) => Result; 104 | export type MapQueryFn< 105 | Input extends ValidValue, 106 | Result extends ValidValue, 107 | > = (x: Handle) => Query; 108 | export type JoinMapFn< 109 | Left extends ValidValue, 110 | Right extends ValidValue, 111 | TMapping extends Mapping, 112 | > = (left: Handle, right: Handle) => TMapping; 113 | 114 | export type FilterFn> = ( 115 | x: Handle 116 | ) => SingleScalarOperand; 117 | export type JoinFilterFn< 118 | Left extends ValidValue, 119 | Right extends ValidValue, 120 | > = ( 121 | left: Handle, 122 | right: Handle 123 | ) => SingleScalarOperand; 124 | 125 | // todo: typed Expr 126 | type InferScalarValue> = [T] extends [ 127 | SingleScalarOperand, 128 | ] 129 | ? K 130 | : never; 131 | type __TestInferScalarValue = Assert, number>>; 132 | 133 | type InferEntityProp = [T] extends [SingleScalarOperand] 134 | ? InferScalarValue 135 | : [T] extends [Query] 136 | ? QueryValue[] 137 | : ConvertEntityMappingToObjectValue; 138 | 139 | type ConvertEntityMappingToObjectValue = { 140 | [K in keyof T]: InferEntityProp; 141 | }; 142 | type __TestConvertObjectMappingToObjectValue = Assert< 143 | [ 144 | Equal< 145 | {a: 1; b: string}, 146 | ConvertEntityMappingToObjectValue<{a: 1; b: string}> 147 | >, 148 | ] 149 | >; 150 | 151 | export type ConvertScalarMappingToScalarValue< 152 | T extends SingleScalarOperand, 153 | > = T extends SingleScalarOperand ? S : never; 154 | 155 | export type ConvertMappingToValue = 156 | IsAny extends true 157 | ? any 158 | : [T] extends [SingleScalarOperand] 159 | ? ConvertScalarMappingToScalarValue 160 | : ConvertEntityMappingToObjectValue; 161 | type __TestConvertMappingToValue = Assert< 162 | [Equal<{a: 1; b: string}, ConvertMappingToValue<{a: 1; b: string}>>] 163 | >; 164 | 165 | export type Expand = 166 | IsAny extends true 167 | ? any 168 | : T extends Date 169 | ? T 170 | : T extends infer O 171 | ? {[K in keyof O]: O[K]} 172 | : never; 173 | 174 | type __TestQuery = [ 175 | Query, 176 | Query, 177 | Query, 178 | Query, 179 | Assert< 180 | [ 181 | Equal, {b: number | null}>, 182 | Equal, User>, 183 | Equal, Post>, 184 | Equal, Comment>, 185 | Equal, {a: number; b: {c: number}}>, 186 | Equal< 187 | QueryValue, 188 | { 189 | id: number; 190 | author_id: number; 191 | text: string; 192 | author: User; 193 | post: Post; 194 | title: string; 195 | comments: Comment[]; 196 | } 197 | >, 198 | Equal, SelfRef>, 199 | ] 200 | >, 201 | ]; 202 | 203 | export type QueryValue> = 204 | T extends Query ? R : never; 205 | 206 | ///////////////////////////////////////////////////////// 207 | 208 | interface User { 209 | id: number; 210 | posts: Post[]; 211 | comments: Comment[]; 212 | } 213 | 214 | interface Post { 215 | id: number; 216 | author_id: number; 217 | title: string; 218 | 219 | author: User; 220 | comments: Comment[]; 221 | } 222 | 223 | interface Comment { 224 | id: number; 225 | author_id: number; 226 | text: string; 227 | 228 | author: User; 229 | post: Post; 230 | } 231 | 232 | interface SelfRef { 233 | id: number; 234 | 235 | ref: SelfRef; 236 | } 237 | 238 | const q1: Query = 1 as any; 239 | const q2: Query = 1 as any; 240 | 241 | const x1 = q1.map(x => ({b: x.author.posts.map(y => y.id).first(y => y)})); 242 | const x2 = q1.map(x => x.author); 243 | const x3 = q1.flatMap(x => x.author.posts); 244 | const x4 = q1.flatMap(x => x.comments).map(x => ({...x})); 245 | const x5 = q1.map(() => ({a: 1, b: {c: 2}})); 246 | const x6 = q1.flatMap(x => x.comments).map(x => ({...x.post, ...x})); 247 | const x7 = q2.map(x => x); 248 | -------------------------------------------------------------------------------- /packages/qustar/src/utils.ts: -------------------------------------------------------------------------------- 1 | import {isObject, isPrimitive} from './literal.js'; 2 | 3 | export function assertNever(x: never, message: string): never { 4 | throw new Error(message + ' | ' + x); 5 | } 6 | 7 | export function assert( 8 | condition: boolean, 9 | message?: string 10 | ): asserts condition { 11 | if (!condition) { 12 | throw new Error(message); 13 | } 14 | } 15 | 16 | export function debugFmt(obj: any) { 17 | return obj.toString(); 18 | } 19 | 20 | export function formatDate(date: Date): string { 21 | const year = date.getFullYear(); 22 | const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-indexed 23 | const day = String(date.getDate()).padStart(2, '0'); 24 | 25 | return `${year}-${month}-${day}`; 26 | } 27 | 28 | export function formatTime(date: Date): string { 29 | const hours = String(date.getHours()).padStart(2, '0'); 30 | const minutes = String(date.getMinutes()).padStart(2, '0'); 31 | const seconds = String(date.getSeconds()).padStart(2, '0'); 32 | 33 | return `${hours}:${minutes}:${seconds}`; 34 | } 35 | 36 | export function formatDateTime(date: Date): string { 37 | return `${formatDate(date)} ${formatTime(date)}`; 38 | } 39 | 40 | export function indent(s: string, depth = 1): string { 41 | return s 42 | .split('\n') 43 | .map(x => ' '.repeat(depth) + x) 44 | .join('\n'); 45 | } 46 | 47 | export function uniqueBy( 48 | items: readonly TItem[], 49 | selector: (x: TItem) => TKey 50 | ): TItem[] { 51 | const keys = new Set(); 52 | const result: TItem[] = []; 53 | for (const item of items) { 54 | const key = selector(item); 55 | if (!keys.has(key)) { 56 | keys.add(key); 57 | result.push(item); 58 | } 59 | } 60 | 61 | return result; 62 | } 63 | 64 | export function compose(): (input: T) => T; 65 | export function compose(fn1: (arg: A) => T): (arg: A) => T; 66 | export function compose( 67 | fn1: (arg: A) => B, 68 | fn2: (arg: B) => T 69 | ): (arg: A) => T; 70 | export function compose( 71 | fn1: (arg: A) => B, 72 | fn2: (arg: B) => C, 73 | fn3: (arg: C) => T 74 | ): (arg: A) => T; 75 | 76 | export function compose(...fns: Function[]) { 77 | if (fns.length === 0) { 78 | return (input: T) => input; 79 | } 80 | if (fns.length === 1) { 81 | return fns[0]; 82 | } 83 | return fns.reverse().reduce( 84 | (prevFn, nextFn) => 85 | (...args: any[]) => 86 | prevFn(nextFn(...args)) 87 | ); 88 | } 89 | 90 | export function arrayEqual(a: string[], b: string[]): boolean { 91 | if (a.length !== b.length) { 92 | return false; 93 | } 94 | 95 | for (let i = 0; i < a.length; i += 1) { 96 | if (a[i] !== b[i]) { 97 | return false; 98 | } 99 | } 100 | 101 | return true; 102 | } 103 | 104 | export function startsWith(a: string[], prefix: string[]): boolean { 105 | if (prefix.length > a.length) { 106 | return false; 107 | } 108 | 109 | for (let i = 0; i < prefix.length; i += 1) { 110 | if (a[i] !== prefix[i]) { 111 | return false; 112 | } 113 | } 114 | 115 | return true; 116 | } 117 | 118 | export function deepEqual(a: any, b: any): boolean { 119 | if (a === b) { 120 | return true; 121 | } 122 | 123 | if ( 124 | a === null || 125 | b === null || 126 | typeof a !== 'object' || 127 | typeof b !== 'object' 128 | ) { 129 | return false; 130 | } 131 | 132 | if (a.constructor !== b.constructor) { 133 | return false; 134 | } 135 | 136 | if (a instanceof Date && b instanceof Date) { 137 | return a.getTime() === b.getTime(); 138 | } 139 | 140 | if (Array.isArray(a)) { 141 | if (a.length !== b.length) { 142 | return false; 143 | } 144 | for (let i = 0; i < a.length; i++) { 145 | if (!deepEqual(a[i], b[i])) { 146 | return false; 147 | } 148 | } 149 | return true; 150 | } 151 | 152 | if (a instanceof Map && b instanceof Map) { 153 | if (a.size !== b.size) { 154 | return false; 155 | } 156 | for (const [key, value] of a) { 157 | if (!b.has(key) || !deepEqual(value, b.get(key))) { 158 | return false; 159 | } 160 | } 161 | return true; 162 | } 163 | 164 | if (a instanceof Set && b instanceof Set) { 165 | if (a.size !== b.size) { 166 | return false; 167 | } 168 | for (const value of a) { 169 | if (!b.has(value)) { 170 | return false; 171 | } 172 | } 173 | return true; 174 | } 175 | 176 | const keysA = Object.keys(a); 177 | const keysB = Object.keys(b); 178 | 179 | if (keysA.length !== keysB.length) { 180 | return false; 181 | } 182 | 183 | for (const key of keysA) { 184 | if (!keysB.includes(key) || !deepEqual(a[key], b[key])) { 185 | return false; 186 | } 187 | } 188 | 189 | return true; 190 | } 191 | 192 | export function like(str: string, pattern: string): boolean { 193 | // Escape special characters in the pattern for regex 194 | let regexPattern = pattern.replace(/([.+?^=!:${}()|[\]/\\])/g, '\\$1'); 195 | 196 | // Replace SQL wildcards with regex wildcards 197 | regexPattern = regexPattern.replace(/%/g, '.*').replace(/_/g, '.'); 198 | 199 | // Create a new regex from the modified pattern 200 | const regex = new RegExp(`^${regexPattern}$`, 'i'); 201 | 202 | // Test the string against the regex 203 | return regex.test(str); 204 | } 205 | 206 | export function compare(a: T, b: T): number { 207 | if (a === b) { 208 | return 0; 209 | } 210 | 211 | if ( 212 | (typeof a === 'number' || typeof a === 'boolean') && 213 | (typeof b === 'number' || typeof b === 'boolean') 214 | ) { 215 | return a > b ? 1 : -1; 216 | } 217 | 218 | if (typeof a === 'string' && typeof b === 'string') { 219 | return a > b ? 1 : -1; 220 | } 221 | 222 | if (a instanceof Date && b instanceof Date) { 223 | return compare(a.getTime(), b.getTime()); 224 | } 225 | 226 | throw new Error(`Unsupported comparison types: ${a} <=> ${b}`); 227 | } 228 | 229 | export function setPath(obj: object, path: string[], value: unknown): void { 230 | let rollingObj: any = obj; 231 | for (const part of path.slice(0, -1)) { 232 | if (!(part in rollingObj)) { 233 | rollingObj[part] = {}; 234 | } 235 | 236 | rollingObj = rollingObj[part]; 237 | } 238 | 239 | const lastPart = path[path.length - 1]; 240 | rollingObj[lastPart] = value; 241 | } 242 | 243 | export type DeepObjectEntry = [path: string[], value: unknown]; 244 | 245 | export function deepEntries(obj: object): DeepObjectEntry[] { 246 | if (!isObject(obj)) { 247 | throw new Error('invalid object: ' + obj); 248 | } 249 | 250 | const result: DeepObjectEntry[] = []; 251 | for (const [key, value] of Object.entries(obj)) { 252 | if (typeof key === 'symbol') { 253 | throw new Error('invalid deep entry key: ' + key); 254 | } 255 | if (isObject(value)) { 256 | result.push( 257 | ...deepEntries(value).map( 258 | ([path, value]): DeepObjectEntry => [[key, ...path], value] 259 | ) 260 | ); 261 | } else if (isPrimitive(value)) { 262 | result.push([[key], value]); 263 | } else { 264 | throw new Error('invalid value: ' + value); 265 | } 266 | } 267 | 268 | return result; 269 | } 270 | 271 | export function isNumberString(value: string) { 272 | return /^(-|\+)?[0-9]+(\.[0-9]+)?$/.test(value); 273 | } 274 | 275 | export function deduplicateFirstWins( 276 | arr: readonly T[], 277 | eq: (a: T, b: T) => boolean 278 | ) { 279 | return arr.filter((val, idx) => arr.findIndex(x => eq(x, val)) === idx); 280 | } 281 | -------------------------------------------------------------------------------- /packages/qustar/tests/snapshot.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, test} from 'vitest'; 2 | import {Query} from '../src/query/query.js'; 3 | import {Q} from '../src/qustar.js'; 4 | 5 | expect.addSnapshotSerializer({ 6 | test(val) { 7 | return typeof val === 'string' && val.startsWith('SELECT'); 8 | }, 9 | print(val) { 10 | return `${(val as string).trim()}`; 11 | }, 12 | }); 13 | 14 | describe('snapshot', () => { 15 | test('eq', () => { 16 | expect( 17 | Query.table({name: 'users', schema: {id: Q.i32()}}) 18 | .filter(x => x.id.eq(1)) 19 | .render('postgresql').sql 20 | ).toMatchInlineSnapshot(/* sql */ ` 21 | SELECT 22 | "s1"."id" 23 | FROM 24 | users AS "s1" 25 | WHERE 26 | "s1"."id" = (0 + 1) 27 | `); 28 | }); 29 | 30 | test('innerJoin', () => { 31 | const comments = Query.table({ 32 | name: 'comments', 33 | schema: {parent_id: Q.i32().null(), id: Q.i32()}, 34 | }); 35 | expect( 36 | comments 37 | .innerJoin({ 38 | right: comments, 39 | select: (child, parent) => ({ 40 | c: child.id, 41 | p: parent.id, 42 | }), 43 | condition: (child, parent) => child.parent_id.eq(parent.id), 44 | }) 45 | .render('postgresql').sql 46 | ).toMatchInlineSnapshot(/* sql */ ` 47 | SELECT 48 | "s1_1"."id" AS "p", 49 | "s1"."id" AS "c" 50 | FROM 51 | comments AS "s1" 52 | INNER JOIN comments AS "s1_1" ON "s1"."parent_id" = "s1_1"."id" 53 | `); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/qustar/tests/types/schema.test-d.ts: -------------------------------------------------------------------------------- 1 | // import {describe, test} from 'vitest'; 2 | // import {EntityDescriptor} from '../../src/descriptor.js'; 3 | // import {Query} from '../../src/index.js'; 4 | // import {Expand} from '../../src/types/query.js'; 5 | // import {DeriveEntity} from '../../src/types/schema.js'; 6 | 7 | // declare function derive( 8 | // _: T 9 | // ): Expand>; 10 | 11 | // // vitest expectTypeOf doesn't work, because DeriveEntity generates too complex type 12 | // declare function check(_: T); 13 | 14 | // interface User { 15 | // id: number; 16 | // } 17 | // declare const users: Query; 18 | 19 | // describe('typescript', () => { 20 | // describe('schema', () => { 21 | // test('i8', () => { 22 | // check<{x: number}>(derive({x: 'i8'})); 23 | // check<{x: number}>(derive>({x: 'i8'})); 24 | // check<{x: number}>(derive({x: {type: 'i8'}})); 25 | // check<{x: number}>(derive>({x: {type: 'i8'}})); 26 | // check<{x: number}>(derive({x: {type: 'i8', nullable: false}})); 27 | // check<{x: number}>( 28 | // derive>({x: {type: 'i8', nullable: false}}) 29 | // ); 30 | // check<{x: number | null}>(derive({x: {type: 'i8', nullable: true}})); 31 | // check<{x: number | null}>( 32 | // derive>({ 33 | // x: {type: 'i8', nullable: true}, 34 | // }) 35 | // ); 36 | // }); 37 | 38 | // test('i16', () => { 39 | // check<{x: number}>(derive({x: 'i16'})); 40 | // check<{x: number}>(derive>({x: 'i16'})); 41 | // check<{x: number}>(derive({x: {type: 'i16'}})); 42 | // check<{x: number}>(derive>({x: {type: 'i16'}})); 43 | // check<{x: number}>(derive({x: {type: 'i16', nullable: false}})); 44 | // check<{x: number}>( 45 | // derive>({x: {type: 'i16', nullable: false}}) 46 | // ); 47 | // check<{x: number | null}>(derive({x: {type: 'i16', nullable: true}})); 48 | // check<{x: number | null}>( 49 | // derive>({ 50 | // x: {type: 'i16', nullable: true}, 51 | // }) 52 | // ); 53 | 54 | // test('i32', () => { 55 | // check<{x: number}>(derive({x: 'i32'})); 56 | // check<{x: number}>(derive>({x: 'i32'})); 57 | // check<{x: number}>(derive({x: {type: 'i32'}})); 58 | // check<{x: number}>( 59 | // derive>({x: {type: 'i32'}}) 60 | // ); 61 | // check<{x: number}>(derive({x: {type: 'i32', nullable: false}})); 62 | // check<{x: number}>( 63 | // derive>({x: {type: 'i32', nullable: false}}) 64 | // ); 65 | // check<{x: number | null}>(derive({x: {type: 'i32', nullable: true}})); 66 | // check<{x: number | null}>( 67 | // derive>({ 68 | // x: {type: 'i32', nullable: true}, 69 | // }) 70 | // ); 71 | // }); 72 | 73 | // test('i64', () => { 74 | // check<{x: number}>(derive({x: 'i64'})); 75 | // check<{x: number}>(derive>({x: 'i64'})); 76 | // check<{x: number}>(derive({x: {type: 'i64'}})); 77 | // check<{x: number}>( 78 | // derive>({x: {type: 'i64'}}) 79 | // ); 80 | // check<{x: number}>(derive({x: {type: 'i64', nullable: false}})); 81 | // check<{x: number}>( 82 | // derive>({x: {type: 'i64', nullable: false}}) 83 | // ); 84 | // check<{x: number | null}>(derive({x: {type: 'i64', nullable: true}})); 85 | // check<{x: number | null}>( 86 | // derive>({ 87 | // x: {type: 'i64', nullable: true}, 88 | // }) 89 | // ); 90 | // }); 91 | 92 | // test('f32', () => { 93 | // check<{x: number}>(derive({x: 'f32'})); 94 | // check<{x: number}>(derive>({x: 'f32'})); 95 | // check<{x: number}>(derive({x: {type: 'f32'}})); 96 | // check<{x: number}>( 97 | // derive>({x: {type: 'f32'}}) 98 | // ); 99 | // check<{x: number}>(derive({x: {type: 'f32', nullable: false}})); 100 | // check<{x: number}>( 101 | // derive>({x: {type: 'f32', nullable: false}}) 102 | // ); 103 | // check<{x: number | null}>(derive({x: {type: 'f32', nullable: true}})); 104 | // check<{x: number | null}>( 105 | // derive>({ 106 | // x: {type: 'f32', nullable: true}, 107 | // }) 108 | // ); 109 | // }); 110 | 111 | // test('f64', () => { 112 | // check<{x: number}>(derive({x: 'f64'})); 113 | // check<{x: number}>(derive>({x: 'f64'})); 114 | // check<{x: number}>(derive({x: {type: 'f64'}})); 115 | // check<{x: number}>( 116 | // derive>({x: {type: 'f64'}}) 117 | // ); 118 | // check<{x: number}>(derive({x: {type: 'f64', nullable: false}})); 119 | // check<{x: number}>( 120 | // derive>({x: {type: 'f64', nullable: false}}) 121 | // ); 122 | // check<{x: number | null}>(derive({x: {type: 'f64', nullable: true}})); 123 | // check<{x: number | null}>( 124 | // derive>({ 125 | // x: {type: 'f64', nullable: true}, 126 | // }) 127 | // ); 128 | // }); 129 | 130 | // test('boolean', () => { 131 | // check<{x: boolean}>(derive({x: 'boolean'})); 132 | // check<{x: boolean}>(derive>({x: 'boolean'})); 133 | // check<{x: boolean}>(derive({x: {type: 'boolean'}})); 134 | // check<{x: boolean}>( 135 | // derive>({x: {type: 'boolean'}}) 136 | // ); 137 | // check<{x: boolean}>(derive({x: {type: 'boolean', nullable: false}})); 138 | // check<{x: boolean}>( 139 | // derive>({ 140 | // x: {type: 'boolean', nullable: false}, 141 | // }) 142 | // ); 143 | // check<{x: boolean | null}>( 144 | // derive({x: {type: 'boolean', nullable: true}}) 145 | // ); 146 | // check<{x: boolean | null}>( 147 | // derive>({ 148 | // x: {type: 'boolean', nullable: true}, 149 | // }) 150 | // ); 151 | // }); 152 | 153 | // test('string', () => { 154 | // check<{x: string}>(derive({x: 'string'})); 155 | // check<{x: string}>(derive>({x: 'string'})); 156 | // check<{x: string}>(derive({x: {type: 'string'}})); 157 | // check<{x: string}>( 158 | // derive>({x: {type: 'string'}}) 159 | // ); 160 | // check<{x: string}>(derive({x: {type: 'string', nullable: false}})); 161 | // check<{x: string}>( 162 | // derive>({ 163 | // x: {type: 'string', nullable: false}, 164 | // }) 165 | // ); 166 | // check<{x: string | null}>(derive({x: {type: 'string', nullable: true}})); 167 | // check<{x: string | null}>( 168 | // derive>({ 169 | // x: {type: 'string', nullable: true}, 170 | // }) 171 | // ); 172 | // }); 173 | // }); 174 | 175 | // test('forward ref', () => { 176 | // check<{x: User}>( 177 | // derive({ 178 | // x: { 179 | // type: 'ref', 180 | // required: true, 181 | // references: () => users, 182 | // condition: () => true, 183 | // }, 184 | // }) 185 | // ); 186 | // check<{x: User}>( 187 | // derive>({ 188 | // x: { 189 | // type: 'ref', 190 | // required: true, 191 | // references: () => users, 192 | // condition: () => true, 193 | // }, 194 | // }) 195 | // ); 196 | 197 | // check<{x: User | null}>( 198 | // derive({ 199 | // x: { 200 | // type: 'ref', 201 | // references: () => users, 202 | // condition: () => true, 203 | // }, 204 | // }) 205 | // ); 206 | // check<{x: User | null}>( 207 | // derive>({ 208 | // x: { 209 | // type: 'ref', 210 | // references: () => users, 211 | // condition: () => true, 212 | // }, 213 | // }) 214 | // ); 215 | 216 | // check<{x: User | null}>( 217 | // derive({ 218 | // x: { 219 | // type: 'ref', 220 | // required: false, 221 | // references: () => users, 222 | // condition: () => true, 223 | // }, 224 | // }) 225 | // ); 226 | // check<{x: User | null}>( 227 | // derive>({ 228 | // x: { 229 | // type: 'ref', 230 | // required: false, 231 | // references: () => users, 232 | // condition: () => true, 233 | // }, 234 | // }) 235 | // ); 236 | // }); 237 | 238 | // test('back ref', () => { 239 | // check<{x: User[]}>( 240 | // derive({ 241 | // x: { 242 | // type: 'back_ref', 243 | // references: () => users, 244 | // condition: () => true, 245 | // }, 246 | // }) 247 | // ); 248 | // check<{x: User[]}>( 249 | // derive>({ 250 | // x: { 251 | // type: 'back_ref', 252 | // references: () => users, 253 | // condition: () => true, 254 | // }, 255 | // }) 256 | // ); 257 | // }); 258 | // }); 259 | // }); 260 | -------------------------------------------------------------------------------- /packages/qustar/tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, test} from 'vitest'; 2 | import { 3 | compare, 4 | deepEntries, 5 | deepEqual, 6 | isNumberString, 7 | like, 8 | setPath, 9 | } from '../src/utils.js'; 10 | 11 | describe('utils', async () => { 12 | test('deepEqual', () => { 13 | expect(deepEqual({a: 1, b: [1, 2]}, {a: 1, b: [1, 2]})).to.equal(true); 14 | expect(deepEqual({a: 1, b: [1, 2]}, {a: 1, b: [2, 1]})).to.equal(false); 15 | expect(deepEqual(new Set([1, 2, 3]), new Set([1, 2, 3]))).to.equal(true); 16 | expect( 17 | deepEqual( 18 | new Map([ 19 | [1, 'a'], 20 | [2, 'b'], 21 | ]), 22 | new Map([ 23 | [1, 'a'], 24 | [2, 'b'], 25 | ]) 26 | ) 27 | ).to.equal(true); 28 | expect(deepEqual(null, null)).to.equal(true); 29 | expect(deepEqual(null, {})).to.equal(false); 30 | expect(deepEqual(new Date(100), new Date(100))).to.equal(true); 31 | expect(deepEqual(new Date(123), new Date(456))).to.equal(false); 32 | }); 33 | 34 | test('like', () => { 35 | expect(like('hello world', 'hello%')).to.equal(true); 36 | expect(like('hello world', 'hello _orld')).to.equal(true); 37 | expect(like('hello world', '%world')).to.equal(true); 38 | expect(like('hello world', 'h_llo w_rld')).to.equal(true); 39 | expect(like('hello world', 'hello%world')).to.equal(true); 40 | expect(like('hello world', 'hello world')).to.equal(true); 41 | expect(like('hello world', 'h_llo%world')).to.equal(true); 42 | expect(like('hellO world', 'H_lLo%wOrld')).to.equal(true); 43 | expect(like('hello world', 'h_llo %w_rld')).to.equal(true); 44 | expect(like('hello world', 'hello')).to.equal(false); 45 | expect(like('hello world', '%llo%')).to.equal(true); 46 | expect(like('hello world', 'world')).to.equal(false); 47 | expect(like('hello world', 'h_llo%w_rld!')).to.equal(false); 48 | }); 49 | 50 | test('compare', () => { 51 | expect(compare(3, 4)).to.equal(-1); 52 | expect(compare(5, 1)).to.equal(1); 53 | expect(compare(7, 7)).to.equal(0); 54 | expect(compare('apple', 'banana')).to.equal(-1); 55 | expect(compare('cherry', 'apple')).to.equal(1); 56 | expect(compare('date', 'date')).to.equal(0); 57 | expect(compare(new Date(200), new Date(200))).to.equal(0); 58 | expect(compare(new Date(100), new Date(200))).to.equal(-1); 59 | expect(compare(new Date(200), new Date(100))).to.equal(1); 60 | }); 61 | 62 | test('setPath', () => { 63 | const cases = [ 64 | {target: {}, path: ['a'], value: 3, expected: {a: 3}}, 65 | {target: {}, path: ['a', 'b'], value: 3, expected: {a: {b: 3}}}, 66 | {target: {a: {}}, path: ['a', 'b'], value: 3, expected: {a: {b: 3}}}, 67 | { 68 | target: {b: {}}, 69 | path: ['a', 'b'], 70 | value: 3, 71 | expected: {b: {}, a: {b: 3}}, 72 | }, 73 | ]; 74 | 75 | for (const {target, path, value, expected} of cases) { 76 | setPath(target, path, value); 77 | expect(target).to.deep.equal(expected); 78 | } 79 | }); 80 | 81 | test('deepEntries', () => { 82 | const cases = [ 83 | { 84 | target: {a: 1, b: 2}, 85 | expected: [ 86 | [['a'], 1], 87 | [['b'], 2], 88 | ], 89 | }, 90 | { 91 | target: {a: 1, b: {c: 2}}, 92 | expected: [ 93 | [['a'], 1], 94 | [['b', 'c'], 2], 95 | ], 96 | }, 97 | ]; 98 | 99 | for (const {target, expected} of cases) { 100 | expect(deepEntries(target)).to.deep.equal(expected); 101 | } 102 | }); 103 | 104 | describe('isNumberString', () => { 105 | test('should return true for valid integer strings', () => { 106 | expect(isNumberString('123')).toBe(true); 107 | expect(isNumberString('-123')).toBe(true); 108 | expect(isNumberString('+123')).toBe(true); 109 | expect(isNumberString('0')).toBe(true); 110 | }); 111 | 112 | test('should return true for valid floating-point strings', () => { 113 | expect(isNumberString('123.456')).toBe(true); 114 | expect(isNumberString('-123.456')).toBe(true); 115 | expect(isNumberString('+123.456')).toBe(true); 116 | expect(isNumberString('0.456')).toBe(true); 117 | }); 118 | 119 | test('should return false for strings with invalid number formats', () => { 120 | expect(isNumberString('123.')).toBe(false); // Trailing dot 121 | expect(isNumberString('.456')).toBe(false); // Leading dot 122 | expect(isNumberString('123..456')).toBe(false); // Multiple dots 123 | expect(isNumberString('123a')).toBe(false); // Invalid character 124 | expect(isNumberString('abc')).toBe(false); // Non-numeric string 125 | expect(isNumberString('')).toBe(false); // Empty string 126 | expect(isNumberString('+-123')).toBe(false); // Multiple signs 127 | }); 128 | 129 | test('should return false for strings that are not numbers', () => { 130 | expect(isNumberString(' ')).toBe(false); // Whitespace 131 | expect(isNumberString('abc123')).toBe(false); // Letters and numbers 132 | expect(isNumberString('123abc')).toBe(false); // Numbers and letters 133 | expect(isNumberString('NaN')).toBe(false); // NaN string 134 | expect(isNumberString('Infinity')).toBe(false); // Infinity string 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /packages/qustar/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/cjs", 5 | "module": "CommonJS", 6 | }, 7 | } -------------------------------------------------------------------------------- /packages/qustar/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./dist/esm" 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | "tests/**/*.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import {writeFile} from 'fs/promises'; 2 | import {run} from './common/utils'; 3 | 4 | (async () => { 5 | await run('rimraf', 'dist'); 6 | await run('tsc'); 7 | await run('tsc', '--outDir', './dist/cjs', '--module', 'commonjs'); 8 | await writeFile( 9 | './dist/esm/package.json', 10 | '{"type":"module","sideEffects":false}' 11 | ); 12 | await writeFile( 13 | './dist/cjs/package.json', 14 | '{"type":"commonjs","sideEffects":false}' 15 | ); 16 | })(); 17 | -------------------------------------------------------------------------------- /scripts/common/example-schema.ts: -------------------------------------------------------------------------------- 1 | export const EXAMPLE_SCHEMA_INIT_SQL = /*sql*/ ` 2 | CREATE TABLE IF NOT EXISTS users ( 3 | id INT NOT NULL, 4 | name TEXT NOT NULL 5 | ); 6 | 7 | CREATE TABLE IF NOT EXISTS posts ( 8 | id INT NOT NULL, 9 | title TEXT NOT NULL, 10 | author_id INT NOT NULL 11 | ); 12 | 13 | CREATE TABLE IF NOT EXISTS comments ( 14 | id INT NOT NULL, 15 | text TEXT NOT NULL, 16 | post_id INT NOT NULL, 17 | commenter_id INT NOT NULL, 18 | deleted BIT NOT NULL, 19 | parent_id INT NULL 20 | ); 21 | 22 | -- 23 | 24 | DELETE FROM users; 25 | INSERT INTO 26 | users 27 | VALUES 28 | (1, 'Dima'), 29 | (2, 'Anna'), 30 | (3, 'Max'); 31 | 32 | DELETE FROM posts; 33 | INSERT INTO 34 | posts 35 | VALUES 36 | (1, 'TypeScript', 1), 37 | (2, 'rust', 1), 38 | (3, 'C#', 1), 39 | (4, 'Ruby', 2), 40 | (5, 'C++', 2), 41 | (6, 'Python', 3); 42 | 43 | DELETE FROM comments; 44 | INSERT INTO 45 | comments(id, text, post_id, commenter_id, deleted, parent_id) 46 | VALUES 47 | (5, 'cool', 1, 1, CAST(0 as BIT), NULL), 48 | (6, '+1', 1, 1, CAST(0 as BIT), 5), 49 | (7, 'me too', 1, 2, CAST(0 as BIT), NULL), 50 | (8, 'nah', 2, 3, CAST(1 as BIT), 5); 51 | `; 52 | -------------------------------------------------------------------------------- /scripts/common/utils.ts: -------------------------------------------------------------------------------- 1 | import {spawn} from 'child_process'; 2 | 3 | export function run(command: string, ...args: string[]): Promise { 4 | console.log(['>', command].concat(args).join(' '), '\n'); 5 | return new Promise((resolve, reject) => { 6 | const publishProcess = spawn(command, args, {stdio: 'inherit'}); 7 | 8 | publishProcess.on('error', error => { 9 | console.error(`Error publishing the package: ${error}`); 10 | reject(error); 11 | }); 12 | 13 | publishProcess.on('close', code => { 14 | if (code !== 0) { 15 | reject(new Error(`command existed with code ${code}`)); 16 | } else { 17 | resolve(); 18 | } 19 | }); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {exec, spawn} from 'child_process'; 3 | import {resolve as _resolve} from 'path'; 4 | import {inc} from 'semver'; 5 | // eslint-disable-next-line n/no-unsupported-features/node-builtins 6 | import {copyFileSync, readFileSync, rmSync} from 'fs'; 7 | import {run} from './common/utils'; 8 | 9 | const packageJsonPath = _resolve(process.cwd(), 'package.json'); 10 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); 11 | const packageName = packageJson.name; 12 | 13 | async function getCurrentVersion() { 14 | try { 15 | const response = await axios.get( 16 | `https://registry.npmjs.org/${packageName}` 17 | ); 18 | return response.data['dist-tags'].latest; 19 | } catch (error: any) { 20 | if (error.response && error.response.status === 404) { 21 | console.log('Package not found. Setting initial version to 0.0.1.'); 22 | return '0.0.1'; 23 | } else { 24 | console.error('Error fetching the current version:', error); 25 | // eslint-disable-next-line n/no-process-exit 26 | process.exit(1); 27 | } 28 | } 29 | } 30 | function updatePackageVersion(newVersion) { 31 | return new Promise((resolve, reject) => { 32 | exec(`npm version ${newVersion}`, error => { 33 | if (error) { 34 | console.error(`Error updating the package version: ${error}`); 35 | // eslint-disable-next-line n/no-process-exit 36 | reject(error); 37 | } 38 | 39 | resolve(); 40 | }); 41 | }); 42 | } 43 | 44 | function publishPackage() { 45 | return new Promise((resolve, reject) => { 46 | const publishProcess = spawn('npm', ['publish'], {stdio: 'inherit'}); 47 | 48 | publishProcess.on('error', error => { 49 | console.error(`Error publishing the package: ${error}`); 50 | reject(error); 51 | }); 52 | 53 | publishProcess.on('close', code => { 54 | if (code !== 0) { 55 | reject(new Error(`npm publish process exited with code ${code}`)); 56 | } else { 57 | resolve(); 58 | } 59 | }); 60 | }); 61 | } 62 | 63 | async function publishPatch() { 64 | try { 65 | const currentVersion = await getCurrentVersion(); 66 | const nextVersion = inc(currentVersion, 'patch'); 67 | 68 | console.log(`Next version: ${nextVersion}`); 69 | 70 | await run('npm', 'run', 'build'); 71 | copyFileSync('../../LICENSE', './LICENSE'); 72 | 73 | console.log('Updating package.json...'); 74 | await updatePackageVersion(nextVersion); 75 | console.log('Publishing new version...'); 76 | await publishPackage(); 77 | 78 | console.log('Package published successfully!'); 79 | } catch (error) { 80 | console.log(`Failed to publish the package: ${error}`); 81 | // eslint-disable-next-line n/no-process-exit 82 | process.exit(1); 83 | } finally { 84 | try { 85 | rmSync('./LICENSE'); 86 | } finally { 87 | console.log('Reverting package.json...'); 88 | await updatePackageVersion('0.0.1'); 89 | } 90 | } 91 | } 92 | 93 | await publishPatch(); 94 | -------------------------------------------------------------------------------- /scripts/playground.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-extraneous-import */ 2 | import {writeFileSync} from 'fs'; 3 | import {Q, Query, QueryTerminatorExpr, compileQuery, optimize} from 'qustar'; 4 | import {BetterSqlite3Connector} from 'qustar-better-sqlite3'; 5 | import {PgConnector} from 'qustar-pg'; 6 | import {Sqlite3Connector} from 'qustar-sqlite3'; 7 | import {createInitSqlScript} from 'qustar-testsuite'; 8 | import {Mysql2Connector} from '../packages/qustar-mysql2/src/mysql2.js'; 9 | 10 | interface ExecOptions { 11 | readonly silent?: boolean; 12 | readonly noOpt?: boolean; 13 | } 14 | 15 | function connect(connector: string) { 16 | if (connector === 'mysql2') { 17 | return new Mysql2Connector('mysql://qustar:test@localhost:22784/qustar'); 18 | } else if (connector === 'pg') { 19 | return new PgConnector('postgresql://qustar:test@localhost:22783'); 20 | } else if (connector === 'sqlite3') { 21 | return new Sqlite3Connector(':memory:'); 22 | } else if (connector === 'better-sqlite3') { 23 | return new BetterSqlite3Connector(':memory:'); 24 | } else { 25 | throw new Error('unknown connector: ' + connector); 26 | } 27 | } 28 | 29 | async function init(variant: string) { 30 | const connector = connect(variant); 31 | 32 | const initScripts = createInitSqlScript('sqlite'); 33 | 34 | for (const script of initScripts) { 35 | await connector.execute(script); 36 | } 37 | 38 | async function execute( 39 | query: Query | QueryTerminatorExpr, 40 | options?: ExecOptions 41 | ) { 42 | try { 43 | const compiledQuery = compileQuery(query, {parameters: true}); 44 | writeFileSync( 45 | './debug/sql-raw.json', 46 | JSON.stringify(compiledQuery, undefined, 2) 47 | ); 48 | const optimizedQuery = options?.noOpt 49 | ? compiledQuery 50 | : optimize(compiledQuery); 51 | const renderedQuery = connector.render(optimizedQuery); 52 | 53 | if (!options?.silent) { 54 | writeFileSync( 55 | './debug/sql-raw.json', 56 | JSON.stringify(compiledQuery, undefined, 2) 57 | ); 58 | writeFileSync( 59 | './debug/sql-opt.json', 60 | JSON.stringify(optimizedQuery, undefined, 2) 61 | ); 62 | 63 | writeFileSync( 64 | './debug/query-raw.sql', 65 | connector.render(compiledQuery).sql 66 | ); 67 | writeFileSync( 68 | './debug/query-opt.sql', 69 | connector.render(optimizedQuery).sql 70 | ); 71 | writeFileSync( 72 | './debug/args.json', 73 | JSON.stringify(renderedQuery.args, undefined, 2) 74 | ); 75 | } 76 | 77 | const rows = await connector.query(renderedQuery); 78 | 79 | if (!options?.silent) { 80 | console.log(renderedQuery.sql); 81 | console.log(); 82 | } 83 | 84 | if (!options?.silent) { 85 | for (const row of rows) { 86 | console.log(row); 87 | } 88 | 89 | console.log(); 90 | } 91 | } catch (err: any) { 92 | if (err.sql && err.joins) { 93 | writeFileSync( 94 | './debug/sql.json', 95 | JSON.stringify(err.sql, undefined, 2) 96 | ); 97 | writeFileSync( 98 | './debug/joins.json', 99 | JSON.stringify(err.joins, undefined, 2) 100 | ); 101 | } 102 | 103 | throw err; 104 | } 105 | } 106 | 107 | return {execute, connector}; 108 | } 109 | 110 | const {execute, connector: connector} = await init('sqlite3'); 111 | try { 112 | const users = Q.table({ 113 | name: 'users', 114 | schema: { 115 | id: Q.i32().generated(), 116 | name: Q.string(), 117 | }, 118 | }); 119 | 120 | // const posts = Q.table({ 121 | // name: 'posts', 122 | // schema: { 123 | // id: Q.i32(), 124 | // title: Q.string(), 125 | // author_id: Q.i32(), 126 | // author: Q.ref({ 127 | // references: () => users, 128 | // condition: (post, user) => post.author_id.eq(user.id), 129 | // }), 130 | // authors: Q.backRef({ 131 | // references: () => users, 132 | // condition: (post, user) => post.author_id.eq(user.id), 133 | // }), 134 | // }, 135 | // }); 136 | 137 | const query = users.map(user => user.id).includes(1); 138 | 139 | // insert 140 | await users.insert({name: 'User'}).execute(connector); 141 | 142 | // update 143 | await users 144 | .filter(user => user.id.eq(42)) 145 | .update(user => ({id: user.id.add(1)})) 146 | .execute(connector); 147 | 148 | // delete 149 | await users.delete(user => user.id.eq(42)).execute(connector); 150 | 151 | execute(query); 152 | } finally { 153 | await connector.close(); 154 | } 155 | -------------------------------------------------------------------------------- /todo.yaml: -------------------------------------------------------------------------------- 1 | roadmap: 2 | - add fetchFirstOrThrow 3 | - add safe run that catches all errors and rethrows them with github issue tracker url 4 | - add qustar/internal for connector API 5 | - add stmt optimization 6 | - add schema tests in qustar package 7 | 8 | docs: 9 | - Usage doc is too verbose, Andrei wants an high level overview like https://diesel.rs/ examples 10 | 11 | bugs: 12 | - nested ref can become nullable after, for example, left join for the right entity, 13 | now we assume that ref condition always takes non-null entities (fixed?) 14 | 15 | features: 16 | - type check 17 | - unselection support 18 | - typescript support 19 | - support schema aliases for columns (in db A, in js B) 20 | - migrations from schema 21 | - transactions 22 | - array support 23 | - support decimal type 24 | - support Date types (date, time, timetz, timestamp, timestamptz) 25 | - support UUID 26 | - support CHAR(N) 27 | - now that we know about all properties in a query, we can throw an error when accessing an unknown prop 28 | - validate that query result is good according to projection schema during materialization 29 | - render single and array literal inline if type/itemType is null (because PostgreSQL can freak out during parsing if placeholder type wasn't specified explicitly via $1::type) 30 | - strict mode: 31 | - raise an error in sqlite if cast to int/float has non numeric chars 32 | - use round in sqlite instead of cast for float -> int 33 | - use = ANY($1::int[]) instead of emulating array literal in PostgreSQL 34 | - allow to derive schema at runtime using a connector 35 | 36 | tech: 37 | - refactor combine query compiler 38 | - noPropertyAccessFromIndexSignature 39 | - noUncheckedIndexedAccess 40 | - no-explicit-any 41 | - make order of condition arguments consistent across descriptor and internal schema 42 | 43 | testing: 44 | - run tests on CI 45 | - run gen tests on CI for 5 minutes 46 | - expr interpreter 47 | - sql interpreter 48 | - optimizer canonical tests 49 | - add exception tests 50 | - union tests 51 | - intersect tests 52 | - date tests 53 | - datetime tests 54 | - datetime with tz tests 55 | - timestamp tests 56 | - add string length tests 57 | - add mean tests 58 | - add tests for every data type 59 | - add a test that selects parameter that is a number (SELECT ?), pg seems to don't work 60 | - separate gen tests for optimizer and interpreter 61 | - interpreter 62 | - gen 63 | - add query expr snapshot tests 64 | - add insert tests 65 | - add update tests 66 | - add delete tests 67 | 68 | query compiler: 69 | - use one join for all props from the same ref 70 | 71 | sql optimizer: 72 | - no join for x.parent.id, just use parent_id 73 | - remove inner (FROM/JOIN) order by if outer has its own order by 74 | - don't lift select from with order if parent is combination 75 | - relax group by opt constraint 76 | - relax limit opt constraint 77 | - remove useless joins after optimization 78 | - optimize non inner joins 79 | 80 | release: 81 | - check all `new Error` 82 | - check all `assertNever` 83 | - fix all errors (throw, assertions) 84 | - JSDoc 85 | - README.md 86 | - website 87 | 88 | typecheck: 89 | - don't allow to parse invalid strings 90 | - don't allow to cast boolean to float 91 | 92 | renderers: 93 | sql: 94 | - sql server 95 | - oracle 96 | - cosmosdb 97 | - casandra 98 | - firebird 99 | - cockroachdb 100 | 101 | done: 102 | - fix better-sqlite3 tests 103 | - add interpreter tests 104 | - Expr.sql 105 | - Query.sql 106 | - Query.schema 107 | - support alias usage in raw sql 108 | - add toLowerCase/toUpperCase 109 | - sqlite support 110 | - postgres support 111 | - fix bug users.orderByDesc(x => x.id).map(x => x.id).first(x => x) 112 | - mariadb 113 | - mysql 114 | - update connectors to use peer-dependency 115 | - extract base vitest config 116 | - examples dir 117 | - list of features at the top 118 | - move comparison out of why and far away 119 | - it's not obvious that map works at SQL level 120 | - insert 121 | - delete 122 | - update 123 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "build", 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | // it was often suggested that libraries compile without esModuleInterop, 8 | // since its use in libraries could force users to adopt it too 9 | "esModuleInterop": false, 10 | "noErrorTruncation": true, 11 | "declaration": true, 12 | "declarationMap": true, 13 | "noImplicitAny": false, 14 | "noEmitOnError": false, 15 | "skipLibCheck": true, 16 | "allowUnreachableCode": false, 17 | "allowUnusedLabels": false, 18 | "forceConsistentCasingInFileNames": true, 19 | "allowSyntheticDefaultImports": true, 20 | "lib": ["es2018"], 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitReturns": true, 23 | "pretty": true, 24 | "sourceMap": true, 25 | "strict": true, 26 | "target": "es2018" 27 | // "noPropertyAccessFromIndexSignature": true, 28 | // "noUncheckedIndexedAccess": true 29 | }, 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import {configDefaults, defineConfig} from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | hideSkippedTests: true, 6 | exclude: [...configDefaults.exclude, './dist/**/*'], 7 | coverage: { 8 | exclude: [...(configDefaults.coverage.exclude ?? [])], 9 | }, 10 | }, 11 | }); 12 | --------------------------------------------------------------------------------