An open API service indexing awesome lists of open source software.

https://github.com/sergio9929/pb-query

A type-safe PocketBase query builder
https://github.com/sergio9929/pb-query

Last synced: about 1 month ago
JSON representation

A type-safe PocketBase query builder

Awesome Lists containing this project

README

        

![@sergio9929/pb-query](docs/banner.webp)

# pb-query 🔍✨

**Build type-safe PocketBase queries with the power of TypeScript.**
*Flexible and strongly-typed, with useful helpers to simplify the querying process.*

[![npm](https://img.shields.io/npm/v/@sergio9929/pb-query)](https://www.npmjs.com/package/@sergio9929/pb-query)
![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?logo=typescript&logoColor=white)

## Features

- **💬 Full TypeScript Integration** – Get autocompletion for fields and type safety based on your schema.
- **🔗 Chainable API** – Easily build complex queries using a functional, intuitive syntax.
- **🛡️ Injection Protection** – Automatically sanitize queries with `pb.filter()`.
- **🧩 Nested Grouping** – Create advanced logic with `.group()`.
- **📅 Date & Array Support** – Seamlessly work with dates and array operations.
- **🔍 Advanced Search** – Perform multi-field searches with a single method call.
- **⚡ Helper Operators** – Use built-in helpers like `.search()`, `.between()`, `.in()`, `.isNull()`, and more.
- **🪝 Works Everywhere** – Use queries both in your app and inside `pb_hooks`.
- **📖 Built-in Documentation** – Get examples and explanations directly in your IDE with JSDoc.

## Installation

```bash
# npm
npm install @sergio9929/pb-query

# pnpm
pnpm add @sergio9929/pb-query

# yarn
yarn add @sergio9929/pb-query
```

## Quick Start

### App

```ts
// example.ts

import { pbQuery } from '@sergio9929/pb-query';
import PocketBase from 'pocketbase';
import type { Post } from './types';

// PocketBase instance
const pb = new PocketBase("https://example.com");

// Build a type-safe query for posts
const query = pbQuery()
.search(['title', 'content', 'tags', 'author'], 'footba')
.and()
.between('created', new Date('2023-01-01'), new Date('2023-12-31'))
.or()
.group((q) =>
q.anyLike('tags', 'sports')
.and()
.greaterThan('priority', 5)
)
.build(pb.filter);

console.log(query);
// (title~'footba' || content~'footba' || tags~'footba' || author~'footba')
// && (created>='2023-01-01 00:00:00.000Z' && created<='2023-12-31 00:00:00.000Z')
// || (tags?~'sports' && priority>5)

// Use your query
const records = await pb.collection("posts").getList(1, 20, {
filter: query,
});
```

> [!IMPORTANT]
> You can use this package without TypeScript, but you would miss out on many of its advantages.

### PocketBase Hooks

[Learn more](https://pocketbase.io/docs/js-overview/)

```js
// pb_hooks/example.pb.js

///

routerAdd("GET", "/example", (e) => {
const { pbQuery } = require('@sergio9929/pb-query');

const { raw, values } = pbQuery()
.search(['title', 'content', 'tags.title', 'author'], 'footba')
.and()
.between('created', new Date('2023-01-01'), new Date('2024-12-31'))
.or()
.group((q) =>
q.anyLike('tags', 'sports')
.and()
.greaterThan('priority', 5)
)
.build();

const records = $app.findRecordsByFilter(
'posts',
raw,
'',
20,
0,
values,
);

return e.json(200, records);
});
```

## Table of Contents

- ✨ [Why pb-query?](#why-pb-query)
- 🧠 [Core Concepts](#core-concepts)
- 🔧 [Basic Operators](#basic-operators)
- 🧩 [Combination Operators](#combination-operators)
- 🛠️ [Multiple Operators](#multiple-operators)
- ⚡ [Helper Operators](#helper-operators)
- 💡 [Tips and Tricks](#tips-and-tricks)
- 📜 [Real-World Recipes](#real-world-recipes)
- 🚨 [Troubleshooting](#troubleshooting)
- 🙏 [Credits](#credits)

## Why pb-query?

Our goal was to build a flexible, strongly-typed query builder with useful helpers to simplify the querying process. But more importantly, we wanted to create a tool that helps prevent errors and provides examples and solid autocompletion in the IDE. This way, when we come back to the project after a long time, we won't need to relearn the intricacies of PocketBase's querying syntax.

### Code Suggestions and JSDoc

Documentation directly in your IDE.

![JSDoc](docs/jsdoc.webp)

Leveraging the power of TypeScript, we provide suggestions based on your schema.

![Field name suggestions](docs/suggestions.webp)

## Core Concepts

### Building the Query

The query is returned (not reset) using `.build()`.

```ts
// ❌ Wrong
const query = pbQuery()
.like('content', 'Top Secret%');

console.log(query); // object with functions
```

```ts
// ✅ Right
const query = pbQuery()
.like('content', 'Top Secret%')
.build();

console.log(query); // { raw: 'content~{:content1}', values: { content1: 'Top Secret%' } }
```

You can use this principle to create dynamic queries:

```ts
const dynamicQuery = pbQuery().like('content', 'Top Secret%');

if (user) {
dynamicQuery.and().equal('author', user.id);
}

const query = dynamicQuery.build();
```

### Parameter Safety

By default, we don't filter your query. Using `.build()` returns the unfiltered query and values separately.

```ts
// ❌ Unfiltered query
const { raw, values } = pbQuery()
.search(['title', 'content', 'tags', 'author.name', 'author.surname'], 'Football')
.build();

console.log(raw); // "content~{:content1}"
console.log(values); // { content1: "Top Secret%" }
```

We expose a filter function, but we recommend using the native `pb.filter()` function instead.

```ts
import PocketBase from 'pocketbase';

// PocketBase instance
const pb = new PocketBase("https://example.com");

// ✅ Filtered query
const query = pbQuery()
.like('content', 'Top Secret%')
.build(pb.filter); // use PocketBase's filter function

console.log(query); // "content~'Top Secret%'"
```

### Key Modifiers

Native PocketBase query modifiers.

```ts
pbQuery()
.equal('title:lower', 'hello world') // Case-insensitive (not needed for .like() operators)
.equal('tags:length', 5) // If array length equals 5
.equal('tags:each', 'Tech'); // If every array element equals 'Tech'
```

## Basic Operators

### Equality Checks

#### `.equal(key, value)`

Matches records where `key` equals `value`.

```ts
pbQuery().equal('author.name', 'Alice'); // name='Alice'
// This is case-sensitive. Use the `:lower` modifier for case-insensitive matching.
pbQuery().equal('author.name:lower', 'alice'); // name:lower='alice'
```

#### `.notEqual(key, value)`

Matches records where `key` is not equal to `value`.

```ts
pbQuery().notEqual('author.name', 'Alice'); // name!='Alice'
// This is case-sensitive. Use the `:lower` modifier for case-insensitive matching.
pbQuery().notEqual('author.name:lower', 'alice'); // name:lower!='alice'
```

### Comparisons

#### `.greaterThan(key, value)`

Matches records where `key` is greater than `value`.

```ts
pbQuery().greaterThan('age', 21); // age>21
```

#### `.greaterThanOrEqual(key, value)`

Matches records where `key` is greater than or equal to `value`.

```ts
pbQuery().greaterThanOrEqual('age', 18); // age>=18
```

#### `.lessThan(key, value)`

Matches records where `key` is less than `value`.

```ts
pbQuery().lessThan('age', 50); // age<50
```

#### `.lessThanOrEqual(key, value)`

Matches records where `key` is less than or equal to `value`.

```ts
pbQuery().lessThanOrEqual('age', 65); // age<=65
```

### Text Search

#### `.like(key, value)`

Matches records where `key` contains `value`.

It is case-insensitive, so the `:lower` modifier is unnecessary.

```ts
// Contains
pbQuery().like('author.name', 'Joh'); // name~'Joh' / name~'%Joh%'
// If not specified, auto-wraps the value in `%` for wildcard matching.
```

```ts
// Starts with
pbQuery().like('author.name', 'Joh%'); // name~'Joh%'
```

```ts
// Ends with
pbQuery().like('author.name', '%Doe'); // name~'%Doe'
```

#### `.notLike(key, value)`

Matches records where `key` doesn't contain `value`.

It is case-insensitive, so the `:lower` modifier is unnecessary.

```ts
// Doesn't contain
pbQuery().notLike('author.name', 'Joh'); // name!~'Joh' / name!~'%Joh%'
// If not specified, auto-wraps the value in `%` for wildcard matching.
```

```ts
// Doesn't start with
pbQuery().notLike('author.name', 'Joh%'); // name!~'Joh%'
```

```ts
// Doesn't end with
pbQuery().notLike('author.name', '%Doe'); // name!~'%Doe'
```

## Combination Operators

### Logical Operators

#### `.and()`

Combines the previous and the next conditions with an `and` logical operator.

```ts
pbQuery().equal('name', 'Alice').and().equal('role', 'admin'); // name='Alice' && role='admin'
```

#### `.or()`

Combines the previous and the next conditions with an `or` logical operator.

```ts
pbQuery().equal('name', 'Alice').or().equal('name', 'Bob'); // name='Alice' || name='Bob'
```

### Grouping

#### `.group(callback)`

Creates a logical group.

```ts
pbQuery().group((q) => q.equal('status', 'active').or().equal('status', 'inactive')); // (status~'active' || status~'inactive')
```

## Multiple Operators

### Any Queries (Any/At least one of)

Useful for queries involving [back-relations](https://pocketbase.io/docs/working-with-relations/#back-relations), [multiple relation](https://pocketbase.io/docs/collections/#relationfield), [multiple select](https://pocketbase.io/docs/collections/#selectfield), or [multiple file](https://pocketbase.io/docs/collections/#filefield).

Return all authors who have published at least one book about "Harry Potter":

```ts
pbQuery().anyLike('books_via_author.title', 'Harry Potter'); // post_via_author.name?~'Harry Potter'
```

Return all authors who have only published books about "Harry Potter":

```ts
pbQuery().like('books_via_author.title', 'Harry Potter'); // post_via_author.name~'Harry Potter'
```

> [!NOTE]
> Back-relations by default are resolved as multiple relation field (see the note with the caveats), meaning that similar to all other multi-valued fields (multiple `relation`, `select`, `file`) by default a "match-all" constraint is applied and if you want "any/at-least-one" type of condition then you'll have to prefix the operator with `?`.
>
> @ganigeorgiev in [#6080](https://github.com/pocketbase/pocketbase/discussions/6080#discussioncomment-11526411)

#### `.anyEqual(key, value)`

Matches records where at least one of the values in the given `key` equals `value`.

```ts
pbQuery().anyEqual('books_via_author.title', 'The Island'); // post_via_author.name?='The Island'

// This is case-sensitive. Use the `:lower` modifier for case-insensitive matching.
pbQuery().anyEqual('books_via_author.title:lower', 'the island'); // post_via_author.name:lower?='the island'
```

#### `.anyNotEqual(key, value)`

Matches records where at least one of the values in the given `key` is not equal to `value`.

```ts
pbQuery().anyNotEqual('books_via_author.title', 'The Island'); // post_via_author.name?!='The Island'

// This is case-sensitive. Use the `:lower` modifier for case-insensitive matching.
pbQuery().anyNotEqual('books_via_author.title:lower', 'the island'); // post_via_author.name:lower?!='the island'
```

#### `.anyGreaterThan(key, value)`

Matches records where at least one of the values in the given `key` is greater than `value`.

```ts
pbQuery().anyGreaterThan('age', 21); // age?>21
```

#### `.anyGreaterThanOrEqual(key, value)`

Matches records where at least one of the values in the given `key` is greater than or equal to `value`.

```ts
pbQuery().anyGreaterThanOrEqual('age', 18); // age?>=18
```

#### `.anyLessThan(key, value)`

Matches records where at least one of the values in the given `key` is less than `value`.

```ts
pbQuery().anyLessThan('age', 50); // age?<50
```

#### `.anyLessThanOrEqual(key, value)`

Matches records where at least one of the values in the given `key` is less than or equal to `value`.

```ts
pbQuery().anyLessThanOrEqual('age', 65); // age?<=65
```

#### `.anyLike(key, value)`

Matches records where at least one of the values in the given `key` contains `value`.

It is case-insensitive, so the `:lower` modifier is unnecessary.

```ts
// Contains
pbQuery().anyLike('author.name', 'Joh'); // name?~'Joh' / name?~'%Joh%'
// If not specified, auto-wraps the value in `%` for wildcard matching.
```

```ts
// Starts with
pbQuery().anyLike('author.name', 'Joh%'); // name?~'Joh%'
```

```ts
// Ends with
pbQuery().anyLike('author.name', '%Doe'); // name?~'%Doe'
```

#### `.anyNotLike(key, value)`

Matches records where at least one of the values in the given `key` doesn't contain `value`.

It is case-insensitive, so the `:lower` modifier is unnecessary.

```ts
// Doesn't contain
pbQuery().anyNotLike('author.name', 'Joh'); // name?!~'Joh' / name?!~'%Joh%'
// If not specified, auto-wraps the value in `%` for wildcard matching.
```

```ts
// Doesn't start with
pbQuery().anyNotLike('author.name', 'Joh%'); // name?!~'Joh%'
```

```ts
// Doesn't end with
pbQuery().anyNotLike('author.name', '%Doe'); // name?!~'%Doe'
```

## Helper Operators

### Multi-Field Search

#### `.search(keys, value)`

Matches records where any of the `keys` contain `value`.

It can be used to perform a full-text search (FTS).

It is case-insensitive, so the `:lower` modifier is unnecessary.

```ts
// Full-text search
pbQuery().search(['title', 'content', 'tags', 'author.name', 'author.surname'], 'Football'); // (title~'Football' || content~'Football' || tags~'Football' || author.name~'Football' || author.surname~'Football')
```

```ts
// Contains
pbQuery().search(['name', 'surname'], 'Joh'); // (name~'Joh' || surname~'Joh') / (name~'%Joh%' || surname~'%Joh%')
// If not specified, auto-wraps the value in `%` for wildcard matching.
```

```ts
// Starts with
pbQuery().search(['name', 'surname'], 'Joh%'); // (name~'Joh%' || surname~'Joh%')
```

```ts
// Ends with
pbQuery().search(['name', 'surname'], '%Doe'); // (name~'%Doe' || surname~'%Doe')
```

#### `.in(key, values)`

Matches records where `key` is in `values`.

```ts
pbQuery().in('id', ['id_1', 'id_2', 'id_3']); // (id='id_1' || id='id_2' || id='id_3')
```

#### `.notIn(key, values)`

Matches records where `key` is not in `values`.

```ts
pbQuery().notIn('age', [18, 21, 30]); // (age!=18 && age!=21 && age!=30)
```

### Ranges

#### `.between(key, from, to)`

Matches records where `key` is between `from` and `to`.

```ts
pbQuery().between('age', 18, 30); // (age>=18 && age<=30)
pbQuery().between('created', new Date('2021-01-01'), new Date('2021-12-31')); // (created>='2021-01-01 00:00:00.000Z' && created<='2021-12-31 00:00:00.000Z')
```

#### `.notBetween(key, from, to)`

Matches records where `key` is not between `from` and `to`.

```ts
pbQuery().notBetween('age', 18, 30); // (age<18 || age>30)
pbQuery().notBetween('created', new Date('2021-01-01'), new Date('2021-12-31')); // (created<'2021-01-01 00:00:00.000Z' || created>'2021-12-31 00:00:00.000Z')
```

### Null Checks

#### `.isNull(key)`

Matches records where `key` is null.

```ts
pbQuery().isNull('name'); // name=''
```

#### `.isNotNull(key)`

Matches records where `key` is not null.

```ts
pbQuery().isNotNull('name'); // name!=''
```

## Tips and tricks

### Typed Query Builders

```ts
// query-builders.ts
export const queryUsers = pbQuery;
export const queryPosts = pbQuery;
```

```ts
// posts.ts
const searchQuery = queryPosts()
.search(['title', 'content', 'tags', 'author'], 'footba')
.build(pb.filter);
```

```ts
// user.ts
const userQuery = queryUsers().equal('username', 'sergio9929').build(pb.filter);
```

### Cloning queries

You can clone queries to create new query builders with an initial state. This is useful when you want to reuse a base query but apply additional conditions independently.

```ts
// Create a base query for sports-related posts
export const querySportsPosts = () => pbQuery()
.anyLike('tags', 'sports')
.and(); // Initial condition: ags?~'sports' &&

const searchQuery1 = querySportsPosts()
.search(['title', 'content', 'tags', 'author'], 'basketba')
.build(pb.filter);
// tags?~'sports' && (title~'basketba' || content~'basketba' || tags~'basketba' || author~'basketba')

const searchQuery2 = querySportsPosts()
.search(['title', 'content', 'tags', 'author'], 'footba')
.build(pb.filter);
// tags?~'sports' && (title~'footba' || content~'footba' || tags~'footba' || author~'footba')
```

#### How Cloning Works

1. **Initial State**: When you clone a query, it captures the current state of the query builder, including all conditions and values.
2. **Independent Instances**: Each cloned query is independent, so modifying one does not affect the others.
3. **Reusability**: Cloning is ideal for creating reusable query templates that can be extended with additional conditions.

## 📜 Real-World Recipes

### Paginated Admin Dashboard

```ts
const buildAdminQuery = (
searchTerm: string,
options: {
minLogins: number;
roles: string[];
statuses: string[];
}
) => pbQuery()
.search(['name', 'email', 'department'], searchTerm)
.and()
.greaterThanOrEqual('loginCount', options.minLogins)
.and()
.in('role', options.roles)
.and()
.group((q) =>
q.in('status', options.statuses)
.or()
.isNull('status')
)
.build(pb.filter);
```

### E-Commerce Product Filter

```ts
const productQuery = pbQuery()
.between('price', minPrice, maxPrice)
.and()
.anyLike('tags', category)
.and()
.lessThan('stock', 5)
.and()
.group((q) =>
q.equal('color', selectedColor)
.or()
.isNotNull('customizationOptions')
)
.build(pb.filter);
```

### Dynamic Search Query

```ts
function buildSearchQuery(term: string, user: User) {
const dynamicQuery = pbQuery().like('content', term).and();

if (user.created < new Date('2020-01-01')) {
return dynamicQuery
.lessThan('created', new Date('2020-01-01'))
.build(pb.filter); // content~'Top Secret' && created<'2020-01-01 00:00:00.000Z'
}

return dynamicQuery
.greaterThanOrEqual('created', new Date('2020-01-01'))
.build(pb.filter); // content~'Top Secret' && created>='2020-01-01 00:00:00.000Z'
}

const searchQuery = buildSearchQuery('Top Secret', user);
```

## Troubleshooting

### Common Issues

**Problem:** Date comparisons not working
**Fix:** Always use Date objects:
```ts
pbQuery().between('created', new Date('2023-01-01'), new Date());
```

### Performance Tips

1. **Set Max Depth for TypeScript**
By default, we infer types up to 6 levels deep. You can change this for each query.

For example, this is 3 levels deep:

```ts
// author.info.age
```

```ts
pbQuery()
.equal('author.info.age', 30)
.and()
.like('author.email', '%@example.com');
// author.info.age=30 && author.email~'%@example.com'
```

## Credits

This project was inspired by [@emresandikci/pocketbase-query](https://github.com/emresandikci/pocketbase-query).

---

**@sergio9929/pb-query** is maintained by [@sergio9929](https://github.com/sergio9929) with ❤️

Found a bug? [Open an issue](https://github.com/sergio9929/pb-query/issues)

Want to contribute? [Read our guide](CONTRIBUTING.md)