also looking at this
test(docs): check the docs against the code, and fix what that found
#1073
13 files
+325
-21
| @@ -18,8 +18,12 @@ generated/ | ||
| 18 | 18 | bin/qbx |
| 19 | 19 | packages/bun-query-builder/bin/qbx |
| 20 | 20 | /packages/bun-query-builder/sql |
| 21 | packages/benchmark/benchmark.db-shm | |
| 22 | packages/benchmark/benchmark.db-wal | |
| 21 | # SQLite scratch databases, wherever a test or a manual run happens to drop | |
| 22 | # them. These were previously listed one path at a time, so `other.db` and its | |
| 23 | # -shm/-wal siblings ended up committed at the repo root. | |
| 24 | *.db | |
| 25 | *.db-shm | |
| 26 | *.db-wal | |
| 23 | 27 | *.sql |
| 24 | 28 | *.csv |
| 25 | 29 | users.json |
| @@ -386,8 +386,8 @@ query-builder wait-ready --attempts 30 --delay 250 | ||
| 386 | 386 | |
| 387 | 387 | # Execute SQL |
| 388 | 388 | query-builder file ./migrations/seed.sql |
| 389 | query-builder unsafe "SELECT _ FROM users WHERE id = $1" --params "[1]" | |
| 390 | query-builder explain "SELECT _ FROM users WHERE active = true" | |
| 389 | query-builder unsafe "SELECT * FROM users WHERE id = $1" --params "[1]" | |
| 390 | query-builder explain "SELECT * FROM users WHERE active = true" | |
| 391 | 391 | |
| 392 | 392 | # Diagrams & Visualization |
| 393 | 393 | query-builder relation:diagram # Generate Mermaid ER diagram |
| @@ -3,6 +3,70 @@ title: Aggregations | ||
| 3 | 3 | description: Perform aggregate calculations with the query builder. |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | # Aggregations | |
| 7 | ||
| 8 | Perform aggregate calculations like COUNT, SUM, AVG, MIN, and MAX with full type safety. | |
| 9 | ||
| 10 | ## Count | |
| 11 | ||
| 12 | Count records in a table: | |
| 13 | ||
| 14 | ```typescript | |
| 15 | import { createQueryBuilder } from 'bun-query-builder' | |
| 16 | ||
| 17 | const db = createQueryBuilder<typeof schema>({ schema, meta }) | |
| 18 | ||
| 19 | // Count all records | |
| 20 | const totalUsers = await db.selectFrom('users').count() | |
| 21 | console.log(totalUsers) // 150 | |
| 22 | ||
| 23 | // Count with conditions | |
| 24 | const activeUsers = await db | |
| 25 | .selectFrom('users') | |
| 26 | .where({ active: true }) | |
| 27 | .count() | |
| 28 | ||
| 29 | // Count specific column (excludes NULL) | |
| 30 | const usersWithEmail = await db.selectFrom('users').count('email') | |
| 31 | ||
| 32 | // Count distinct values | |
| 33 | const uniqueCountries = await db.selectFrom('users').countDistinct('country') | |
| 34 | ``` | |
| 35 | ||
| 36 | ## Sum | |
| 37 | ||
| 38 | Calculate the sum of a column: | |
| 39 | ||
| 40 | ```typescript | |
| 41 | // Sum of all order amounts | |
| 42 | const totalRevenue = await db.selectFrom('orders').sum('amount') | |
| 43 | console.log(totalRevenue) // 125000.50 | |
| 44 | ||
| 45 | // Sum with conditions | |
| 46 | const yearlyRevenue = await db | |
| 47 | .selectFrom('orders') | |
| 48 | .where('created_at', '>=', '2024-01-01') | |
| 49 | .sum('amount') | |
| 50 | ``` | |
| 51 | ||
| 52 | ## Average | |
| 53 | ||
| 54 | Calculate the average of a column: | |
| 55 | ||
| 56 | ```typescript | |
| 57 | // Average age of users | |
| 58 | const avgAge = await db.selectFrom('users').avg('age') | |
| 59 | console.log(avgAge) // 32.5 | |
| 60 | ||
| 61 | // Average with conditions | |
| 62 | const avgActiveUserAge = await db | |
| 63 | .selectFrom('users') | |
| 64 | .where({ active: true }) | |
| 65 | .avg('age') | |
| 66 | ``` | |
| 67 | ||
| 68 | ## Max | |
| 69 | ||
| 6 | 70 | Get the maximum value: |
| 7 | 71 | |
| 8 | 72 | ```typescript |
| @@ -872,9 +872,13 @@ query-builder migrate:status # Verify migration state | ||
| 872 | 872 | - **Access Restrictions**: Limit who can use `unsafe` command in production |
| 873 | 873 | |
| 874 | 874 | ```bash |
| 875 | # Good: Use environment variables for sensitive data | |
| 875 | # Good: Use environment variables for sensitive data. | |
| 876 | # DB_CONNECTION must name the dialect — it is what arms the rest of the | |
| 877 | # DB_* connection variables. Without it they are ignored. | |
| 878 | export DB_CONNECTION=postgres | |
| 876 | 879 | export DB_HOST=production.db.company.com |
| 877 | export DB_USER=readonly_user | |
| 880 | export DB_USERNAME=readonly_user | |
| 881 | export DB_PASSWORD="$PGPASSWORD" | |
| 878 | 882 | query-builder ping |
| 879 | 883 | |
| 880 | 884 | # Good: Validate parameters before use |
| @@ -465,20 +465,34 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ | ||
| 465 | 465 | |
| 466 | 466 | ### Environment-Specific Configurations |
| 467 | 467 | |
| 468 | These are every environment variable the library reads: | |
| 469 | ||
| 468 | 470 | ```bash |
| 469 | # Development environment | |
| 470 | export DB_TIMEOUT=10000 | |
| 471 | export DB_RETRY_ATTEMPTS=3 | |
| 472 | ||
| 473 | # Production environment | |
| 474 | export DB_TIMEOUT=5000 | |
| 475 | export DB_RETRY_ATTEMPTS=1 | |
| 476 | export DB_HEALTH_CHECK_INTERVAL=30 | |
| 477 | ||
| 478 | # Testing environment | |
| 479 | export DB_TIMEOUT=15000 | |
| 480 | export DB_RETRY_ATTEMPTS=5 | |
| 481 | export DB_WAIT_READY_ATTEMPTS=30 | |
| 471 | # Which dialect to connect with. This one is load-bearing: it also ARMS the | |
| 472 | # connection variables below, which are ignored unless it is set and matches | |
| 473 | # the dialect in use. DB_DIALECT is an accepted alias. | |
| 474 | export DB_CONNECTION=postgres | |
| 475 | ||
| 476 | # Connection details, honoured only when DB_CONNECTION is set as above | |
| 477 | export DB_HOST=localhost | |
| 478 | export DB_PORT=5432 | |
| 479 | export DB_USERNAME=postgres | |
| 480 | export DB_PASSWORD=secret | |
| 481 | export DB_DATABASE=my_app | |
| 482 | ||
| 483 | # Require TLS ('true' or '1'). Read on its own, no DB_CONNECTION needed. | |
| 484 | export DB_SSL=true | |
| 485 | ``` | |
| 486 | ||
| 487 | Anything not on this list has no effect. Timeouts, retry counts and health-check | |
| 488 | intervals are configuration, not environment — set them in | |
| 489 | `query-builder.config.ts` or via `setConfig()`: | |
| 490 | ||
| 491 | ```ts | |
| 492 | setConfig({ | |
| 493 | transactionDefaults: { retries: 3, backoff: { baseMs: 100, factor: 2, maxMs: 2000, jitter: true } }, | |
| 494 | database: { pool: { acquireTimeoutMs: 5000 } }, | |
| 495 | }) | |
| 482 | 496 | ``` |
| 483 | 497 | |
| 484 | 498 | The CLI is designed to integrate seamlessly with modern development workflows, from local development to production monitoring. Choose the right commands and options for your specific use case and environment. |
other.dbdeleted+0-0
Binary file. Nothing to show line by line.
other.db-shmdeleted+0-0
Binary file. Nothing to show line by line.
other.db-waldeleted+0-0
No changes to show.
packages/bun-query-builder/other.dbdeleted+0-0
Binary file. Nothing to show line by line.
packages/bun-query-builder/other.db-shmdeleted+0-0
Binary file. Nothing to show line by line.
packages/bun-query-builder/other.db-waldeleted+0-0
No changes to show.
| @@ -0,0 +1,220 @@ | ||
| 1 | /** | |
| 2 | * Mechanical checks on the documentation. | |
| 3 | * | |
| 4 | * Three classes of doc breakage reached `main` and survived months, because | |
| 5 | * nothing verified the docs against the code: | |
| 6 | * | |
| 7 | * - ~40 documented calls to builder methods that do not exist (`db.update`, | |
| 8 | * `db.insert`, `db.delete`, `db.execute`, `db.beginTransaction`, …). Every | |
| 9 | * one threw on its first line, on every dialect, and `docs/api/reference.md` | |
| 10 | * listed one of them in its method table. | |
| 11 | * - Six pages truncated mid-code-block by a lint-fix commit, each starting on | |
| 12 | * an orphaned expression with no H1 and an unbalanced fence. | |
| 13 | * - 132 identifiers whose `_` had been rewritten to `*` — `created*at`, | |
| 14 | * `user*id` — and the multiplication in `7 * 24 * 60 * 60 * 1000` rewritten | |
| 15 | * the other way. One line read `60 _ 60 * 1000`, half converted. | |
| 16 | * | |
| 17 | * None of these need a database or a network, so they are cheap to assert. | |
| 18 | * The builder-method check is the load-bearing one: it compares every | |
| 19 | * documented call against the live export list rather than a hand-kept copy, | |
| 20 | * so it cannot drift as the API grows. | |
| 21 | */ | |
| 22 | ||
| 23 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 24 | import { extname, join, relative, resolve } from 'node:path' | |
| 25 | import { describe, expect, it } from 'bun:test' | |
| 26 | import { buildDatabaseSchema, createQueryBuilder } from '../src/index' | |
| 27 | ||
| 28 | const REPO_ROOT = resolve(import.meta.dir, '../../..') | |
| 29 | const DOCS_DIR = join(REPO_ROOT, 'docs') | |
| 30 | ||
| 31 | function markdownFiles(dir: string): string[] { | |
| 32 | const out: string[] = [] | |
| 33 | for (const entry of readdirSync(dir, { withFileTypes: true })) { | |
| 34 | const full = join(dir, entry.name) | |
| 35 | // `public/` holds images and other binaries. | |
| 36 | if (entry.isDirectory()) { | |
| 37 | if (entry.name !== 'public') | |
| 38 | out.push(...markdownFiles(full)) | |
| 39 | continue | |
| 40 | } | |
| 41 | if (extname(entry.name) === '.md') | |
| 42 | out.push(full) | |
| 43 | } | |
| 44 | return out.sort() | |
| 45 | } | |
| 46 | ||
| 47 | const DOC_FILES = markdownFiles(DOCS_DIR) | |
| 48 | const READMES = [join(REPO_ROOT, 'README.md'), join(REPO_ROOT, 'packages/bun-query-builder/README.md')] | |
| 49 | .filter(p => statSync(p, { throwIfNoEntry: false })) | |
| 50 | const ALL_FILES = [...DOC_FILES, ...READMES] | |
| 51 | ||
| 52 | const rel = (p: string) => relative(REPO_ROOT, p) | |
| 53 | ||
| 54 | describe('docs lint', () => { | |
| 55 | it('finds documentation to check', () => { | |
| 56 | // Guards the guard: a bad glob would make every assertion below vacuous. | |
| 57 | expect(DOC_FILES.length).toBeGreaterThan(20) | |
| 58 | }) | |
| 59 | ||
| 60 | it('closes every code fence', () => { | |
| 61 | // An odd count means a page is truncated mid-block — the exact signature of | |
| 62 | // the six pages that lost their heads. | |
| 63 | const offenders: string[] = [] | |
| 64 | for (const file of ALL_FILES) { | |
| 65 | const fences = readFileSync(file, 'utf8').split('\n').filter(l => l.startsWith('```')).length | |
| 66 | if (fences % 2 !== 0) | |
| 67 | offenders.push(`${rel(file)} (${fences} fences)`) | |
| 68 | } | |
| 69 | expect(offenders).toEqual([]) | |
| 70 | }) | |
| 71 | ||
| 72 | it('starts every prose page with a heading', () => { | |
| 73 | // The truncated pages opened on an orphaned `.get()` instead. This is what | |
| 74 | // caught `docs/aggregations.md`, a seventh casualty of the same commit that | |
| 75 | // the first repair pass missed. | |
| 76 | const offenders: string[] = [] | |
| 77 | for (const file of DOC_FILES) { | |
| 78 | const raw = readFileSync(file, 'utf8') | |
| 79 | const lines = raw.split('\n') | |
| 80 | let i = 0 | |
| 81 | let frontmatter = '' | |
| 82 | if (lines[0]?.trim() === '---') { | |
| 83 | i = 1 | |
| 84 | while (i < lines.length && lines[i].trim() !== '---') { | |
| 85 | frontmatter += `${lines[i]}\n` | |
| 86 | i++ | |
| 87 | } | |
| 88 | i++ | |
| 89 | } | |
| 90 | // `layout: home` / `layout: page` pages (index, team) render from | |
| 91 | // frontmatter and legitimately have no heading in the body. | |
| 92 | if (/^layout:/m.test(frontmatter)) | |
| 93 | continue | |
| 94 | ||
| 95 | while (i < lines.length && lines[i].trim() === '') i++ | |
| 96 | // A leading HTML block — the centred logo on intro.md — is fine, so long | |
| 97 | // as a heading follows it. | |
| 98 | if (lines[i]?.startsWith('<')) { | |
| 99 | while (i < lines.length && !lines[i].startsWith('#') && lines[i].trim() !== '') i++ | |
| 100 | while (i < lines.length && lines[i].trim() === '') i++ | |
| 101 | } | |
| 102 | if (!lines[i]?.startsWith('#')) | |
| 103 | offenders.push(`${rel(file)} starts with: ${JSON.stringify(lines[i]?.slice(0, 60) ?? '')}`) | |
| 104 | } | |
| 105 | expect(offenders).toEqual([]) | |
| 106 | }) | |
| 107 | ||
| 108 | it('has no identifiers whose underscore was rewritten as an asterisk', () => { | |
| 109 | // `created*at`, `user*id`. Deliberately narrow so `SELECT *`, `COUNT(*)` | |
| 110 | // and `**bold**` are untouched: the asterisk must sit between two | |
| 111 | // alphanumerics with no spaces. | |
| 112 | const offenders: string[] = [] | |
| 113 | for (const file of ALL_FILES) { | |
| 114 | readFileSync(file, 'utf8').split('\n').forEach((line, n) => { | |
| 115 | const hit = line.match(/[A-Za-z0-9]\*[A-Za-z0-9]/) | |
| 116 | if (hit) | |
| 117 | offenders.push(`${rel(file)}:${n + 1} ${hit[0]}`) | |
| 118 | }) | |
| 119 | } | |
| 120 | expect(offenders).toEqual([]) | |
| 121 | }) | |
| 122 | ||
| 123 | it('has no multiplication rewritten as an underscore', () => { | |
| 124 | // `7 _ 24 _ 60 _ 60 _ 1000` was `7 * 24 * 60 * 60 * 1000`. | |
| 125 | const offenders: string[] = [] | |
| 126 | for (const file of ALL_FILES) { | |
| 127 | readFileSync(file, 'utf8').split('\n').forEach((line, n) => { | |
| 128 | const hit = line.match(/[A-Za-z0-9_)\]] _ [A-Za-z0-9(]/) | |
| 129 | if (hit) | |
| 130 | offenders.push(`${rel(file)}:${n + 1} ${hit[0]}`) | |
| 131 | }) | |
| 132 | } | |
| 133 | expect(offenders).toEqual([]) | |
| 134 | }) | |
| 135 | ||
| 136 | it('only documents builder methods that exist', () => { | |
| 137 | // Compared against the live builder rather than a hand-kept list, so the | |
| 138 | // check keeps working as the API grows. | |
| 139 | const schema = buildDatabaseSchema({ | |
| 140 | User: { name: 'User', table: 'users', primaryKey: 'id', attributes: { id: { validation: { rule: {} } } } }, | |
| 141 | } as any) | |
| 142 | const builder = createQueryBuilder<typeof schema>({ schema }) as Record<string, unknown> | |
| 143 | const methods = new Set(Object.keys(builder)) | |
| 144 | // The same receiver names hold chain objects in the docs' examples | |
| 145 | // (`const q = db.selectFrom('users'); q.where(...)`), so a chain's methods | |
| 146 | // are equally legitimate. Collect them from a real chain rather than | |
| 147 | // listing them, for the same reason as the builder itself. | |
| 148 | for (const chain of [ | |
| 149 | (builder as any).selectFrom('users'), | |
| 150 | (builder as any).insertInto('users'), | |
| 151 | (builder as any).updateTable('users'), | |
| 152 | (builder as any).deleteFrom('users'), | |
| 153 | ]) { | |
| 154 | let obj = chain | |
| 155 | while (obj && obj !== Object.prototype) { | |
| 156 | for (const k of Object.getOwnPropertyNames(obj)) methods.add(k) | |
| 157 | obj = Object.getPrototypeOf(obj) | |
| 158 | } | |
| 159 | } | |
| 160 | expect(methods.size).toBeGreaterThan(40) | |
| 161 | ||
| 162 | // Receivers that are a query builder in the docs' own examples. | |
| 163 | const CALL = /\b(?:db|trx|tx|sp|innerTrx|qb)\.([a-zA-Z]+)\(/g | |
| 164 | const offenders: string[] = [] | |
| 165 | for (const file of ALL_FILES) { | |
| 166 | readFileSync(file, 'utf8').split('\n').forEach((line, n) => { | |
| 167 | for (const m of line.matchAll(CALL)) { | |
| 168 | if (!methods.has(m[1])) | |
| 169 | offenders.push(`${rel(file)}:${n + 1} .${m[1]}()`) | |
| 170 | } | |
| 171 | }) | |
| 172 | } | |
| 173 | expect(offenders).toEqual([]) | |
| 174 | }) | |
| 175 | ||
| 176 | it('only documents environment variables the library reads', () => { | |
| 177 | // `DB_USER`, `DB_TIMEOUT`, `DB_RETRY_ATTEMPTS` and friends were documented | |
| 178 | // for a long time and read nowhere, so following the docs did nothing and | |
| 179 | // reported no error. | |
| 180 | const srcDir = resolve(import.meta.dir, '../src') | |
| 181 | const sources: string[] = [] | |
| 182 | const walk = (dir: string): void => { | |
| 183 | for (const entry of readdirSync(dir, { withFileTypes: true })) { | |
| 184 | const full = join(dir, entry.name) | |
| 185 | if (entry.isDirectory()) | |
| 186 | walk(full) | |
| 187 | else if (extname(entry.name) === '.ts') | |
| 188 | sources.push(readFileSync(full, 'utf8')) | |
| 189 | } | |
| 190 | } | |
| 191 | walk(srcDir) | |
| 192 | const source = sources.join('\n') | |
| 193 | ||
| 194 | // `createConnectionString` does `const e = process.env` and then reads | |
| 195 | // `e.DB_HOST`, so a scan for `process.env.DB_*` alone reports variables as | |
| 196 | // unread when they are not. Match the property access on any receiver, and | |
| 197 | // strip comments first so a prose mention does not count as a read. | |
| 198 | const code = source | |
| 199 | .replace(/\/\*[\s\S]*?\*\//g, '') | |
| 200 | .split('\n') | |
| 201 | .map(l => l.replace(/\/\/.*$/, '')) | |
| 202 | .join('\n') | |
| 203 | ||
| 204 | const read = new Set<string>() | |
| 205 | for (const m of code.matchAll(/\.(DB_[A-Z0-9_]+)\b/g)) read.add(m[1]) | |
| 206 | for (const m of code.matchAll(/\[['"](DB_[A-Z0-9_]+)['"]\]/g)) read.add(m[1]) | |
| 207 | expect(read.size).toBeGreaterThan(0) | |
| 208 | ||
| 209 | const offenders: string[] = [] | |
| 210 | for (const file of ALL_FILES) { | |
| 211 | readFileSync(file, 'utf8').split('\n').forEach((line, n) => { | |
| 212 | for (const m of line.matchAll(/\b(DB_[A-Z0-9_]{2,})\b/g)) { | |
| 213 | if (!read.has(m[1])) | |
| 214 | offenders.push(`${rel(file)}:${n + 1} ${m[1]}`) | |
| 215 | } | |
| 216 | }) | |
| 217 | } | |
| 218 | expect(offenders).toEqual([]) | |
| 219 | }) | |
| 220 | }) | |