Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
webda2l committed Oct 15, 2024
1 parent 57ac7ec commit b90edff
Show file tree
Hide file tree
Showing 13 changed files with 2,236 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = false
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Continuous Integration

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Run Biome
run: pnpm run biome
- name: Run tests
run: pnpm test
39 changes: 39 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Publish Package to npm

on:
release:
types: [created]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- run: pnpm install
- run: pnpm test

publish-npm:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org/
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- run: pnpm install
- run: pnpm run build
- run: pnpm publish
env:
NODE_AUTH_TOKEN: ${{secrets.npm_token}}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/node_modules
/dist
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
179 changes: 179 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# SchemQl

SchemQl is a lightweight TypeScript library that enhances your SQL workflow by combining raw SQL with targeted type safety and schema validation. It offers two main features:

- **Robust Query Validation**: Ensures the integrity of your query parameters and results through powerful schema validation, reducing runtime errors and data inconsistencies.
- **Selective SQL Typing**: Leverages TypeScript to provide real-time autocomplete and validation for specific parts of your SQL queries, focusing on literal string parameters for tables, columns, parameters, and selections.

SchemQl is designed to complement your existing SQL practices, not replace them. It allows you to write raw SQL while benefiting from enhanced safety for specific query elements. Key characteristics include:

- **Database Agnostic**: Works with any database management system (DBMS), allowing you to fully utilize your database's specific features.
- **SQL-First**: Provides the freedom to write complex SQL queries while offering targeted type safety for literal string parameters.
- **Lightweight**: Focused on core features and intends to remain so.
- **Selectively Type-Safe**: Offers TypeScript support for enhanced developer experience with literal string parameters, balancing flexibility and safety.

SchemQl is ideal for developers who appreciate the power of raw SQL but want added security and convenience through schema validation and targeted TypeScript integration for specific query elements.

This library relies solely on [Zod](https://github.com/colinhacks/zod), though future development could include support for [@effect/schema](https://effect.website/docs/guides/schema/getting-started) as well.


## Installation

To install SchemQl, use npm:

```bash
npm install schemql
```

## Usage

Here's a basic example of how to use SchemQl:

<details>
<summary>1. Create your database schema and expose it with a DB interface</summary>

```typescript
// Advice: use your favorite AI to generate your Zod schema from your SQL

import { parseJsonPreprocessor } from '@/index'
import { z } from 'zod'

export const zUserDb = z.object({
id: z.string(),
email: z.string(),
metadata: z.preprocess(
parseJsonPreprocessor, // Optionally let Zod handle JSON parsing if you use JSON data
z.object({
role: z.enum(['user', 'admin']).default('user'),
})
),
created_at: z.number().int(),
disabled_at: z.number().int().nullable(),
})

type UserDb = z.infer<typeof zUserDb>

// ...

export interface DB {
users: UserDb
// ...other mappings
}
```
</details>

<details>
<summary>2. Initialize your instance of SchemQl with the DB interface typing</summary>

```typescript
// Example with better-sqlite3, but you use your favorite

import SQLite from 'better-sqlite3'
import type { DB } from '@/schema'

const db = new SQLite('sqlite.db')

const schemQl = new SchemQl<DB>({
queryFns: { // Optional at this level, but eases usage
first: (sql, params) => {
const stmt = db.prepare(sql)
return stmt.get(params)
},
firstOrThrow: (sql, params) => {
const stmt = db.prepare(sql)
const first = stmt.get(params)
if (first === undefined) {
throw new Error('No result found')
}
return first
},
all: (sql, params) => {
const stmt = db.prepare(sql)
return params ? stmt.all(params) : stmt.all()
}
},
shouldStringifyObjectParams: true, // Optional. If you use JSON data, you can let SchemQl handle the stringification of your params
})
```
</details>

<details open>
<summary>3. Use your instance of SchemQl to `.first()` / `.firstOrThrow()` / `.all()`</summary>

```typescript
// Simple use with resultSchema only and no SQL literal string
const allUsers = await schemQl.all({
resultSchema: zUserDb.array(),
})(`
SELECT *
FROM users
`)

// More advanced
const firstUser = await schemQl.first({
params: { id: 'uuid-1' },
paramsSchema: zUserDb.pick({ id: true }),
resultSchema: z.object({ user_id: zUserDb.shape.id, length_id: z.number() }),
})((s) => s.sql`
SELECT
${'@users.id'} AS ${'$user_id'},
LENGTH(${'@users.id'}) AS ${'$length_id'}
FROM ${'@users'}
WHERE
${'@users.id'} = ${':id'}
`);

const allUsersLimit = await schemQl.all({
params: { limit: 10 },
resultSchema: zUserDb.array(),
})((s) => s.sql`
SELECT
${'@users.*'}
FROM ${'@users'}
LIMIT ${':limit'}
`)

const allUsersPaginated = await schemQl.all({
params: {
limit: data.query.limit + 1,
cursor: data.query.cursor,
dir: data.query.dir,
},
paramsSchema: zRequestQuery,
resultSchema: zUserDb.array(),
})((s) => s.sql`
SELECT
${'@users.*'}
FROM ${'@users'}
${s.sqlCond(
!!data.query.cursor,
s.sql`WHERE ${'@users.id'} ${s.sqlRaw(data.query.dir === 'next' ? '>' : '<')} ${':cursor'}`
)}
ORDER BY ${'@users.id'} ${s.sqlCond(data.query.dir === 'prev', 'DESC', 'ASC')}
LIMIT ${':limit'}
`)
```
</details>

## Literal String SQL Helpers

| Helper Syntax | Raw SQL Result | Description |
|--------------------------------:|-----------------------:|--------------------------------------------------|
| ${'@table1'} | table1 | Prefix `@` eases table selection/validation |
| ${'@table1.col1'} | table1.col1 | ... and column selection/validation |
| ${'@table1.col1-'} | col1 | ... ending `-` excludes the table name (Useful when table renamed) |
| ${"@table1.col1->'json1'"} | table1.col1->'json1' | ... similar with JSON field selection |
| ${"@table1.col1->>'json1'"} | table1.col1->>'json1' | ... similar with JSON field selection (raw) |
| ${'$resultCol1'} | resultCol1 | Prefix `$` eases selection/validation of fields expected by the resultSchema |
| ${':param1'} | :param1 | Prefix `:` eases selection/validation of expected params |
| ${{ table1: ['col1', 'col2'] }} | table1 (col1, col2) | `object` eases generation of INSERT/UPDATE queries |
| ${s.sqlCond(1, 'ASC', 'DESC')} | ASC | `s.sqlCond` eases generation of conditional SQL |
| ${s.sqlRaw(var)} | var | `s.sqlRaw` eases generation of raw SQL |

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

## License

This project is licensed under the MIT License.
84 changes: 84 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.3/schema.json",
"formatter": {
"useEditorconfig": true,
"formatWithErrors": true,
"indentStyle": "space",
"lineWidth": 120
},
"linter": {
"rules": {
"recommended": true,
"complexity": {
"noUselessUndefinedInitialization": "error",
"noBannedTypes": "off"
},
"correctness": {
"noUnusedImports": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"useHookAtTopLevel": "error"
},
"nursery": {
"noDuplicateCustomProperties": "error",
"noDynamicNamespaceImportAccess": "error",
"noIrregularWhitespace": "error",
"noUselessEscapeInRegex": "error",
"useTrimStartEnd": "error",
"noDuplicateElseIf": "error"
},
"style": {
"noYodaExpression": "error",
"useThrowOnlyError": "error",
"noNonNullAssertion": "warn",
"useBlockStatements": "error",
"useCollapsedElseIf": "error",
"useFilenamingConvention": "error",
"useForOf": "error",
"useFragmentSyntax": "error",
"useNamingConvention": {
"level": "warn",
"options": {
"strictCase": false,
"conventions": [
{
"selector": {
"kind": "objectLiteralMember"
},
"formats": ["camelCase", "snake_case"]
}
]
}
},
"useShorthandArrayType": "error",
"useShorthandAssign": "error",
"useSingleCaseStatement": "error"
},
"suspicious": {
"noDuplicateAtImportRules": "error",
"noEmptyBlock": "error",
"useErrorMessage": "error",
"useAwait": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "es5",
"semicolons": "asNeeded"
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
},
"vcs": {
"enabled": true,
"clientKind": "git"
},
"files": {
"ignoreUnknown": true
}
}
Loading

0 comments on commit b90edff

Please sign in to comment.