@sergio9929/pb-query v0.2.9
pb-query 🔍✨
Build type-safe PocketBase queries with the power of TypeScript.
Flexible and strongly-typed, with useful helpers to simplify the querying process.
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
# npm
npm install @sergio9929/pb-query
# pnpm
pnpm add @sergio9929/pb-query
# yarn
yarn add @sergio9929/pb-query
Quick Start
App
// 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<Post>()
.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
// pb_hooks/example.pb.js
/// <reference path="../pb_data/types.d.ts" />
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?
- 🧠 Core Concepts
- 🔧 Basic Operators
- 🧩 Combination Operators
- 🛠️ Multiple Operators
- ⚡ Helper Operators
- 💡 Tips and Tricks
- 📜 Real-World Recipes
- 🚨 Troubleshooting
- 🙏 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.
Leveraging the power of TypeScript, we provide suggestions based on your schema.
Core Concepts
Building the Query
The query is returned (not reset) using .build()
.
// ❌ Wrong
const query = pbQuery<Post>()
.like('content', 'Top Secret%');
console.log(query); // object with functions
// ✅ Right
const query = pbQuery<Post>()
.like('content', 'Top Secret%')
.build();
console.log(query); // { raw: 'content~{:content1}', values: { content1: 'Top Secret%' } }
You can use this principle to create dynamic queries:
const dynamicQuery = pbQuery<Post>().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.
// ❌ Unfiltered query
const { raw, values } = pbQuery<Post>()
.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.
import PocketBase from 'pocketbase';
// PocketBase instance
const pb = new PocketBase("https://example.com");
// ✅ Filtered query
const query = pbQuery<Post>()
.like('content', 'Top Secret%')
.build(pb.filter); // use PocketBase's filter function
console.log(query); // "content~'Top Secret%'"
Key Modifiers
Native PocketBase query modifiers are supported:
:lower
– Case-insensitive matching (not needed for.like()
operators).:length
– Array length check.:each
– Array each element check.
pbQuery<Post>()
.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'
Macros
Native PocketBase datetime macros are supported: @now
, @yesterday
, @tomorrow
, @todayStart
, @todayEnd
, @monthStart
, @monthEnd
, @yearStart
, @yearEnd
@now
– Current datetime.@yesterday
– 24 hours before@now
.@tomorrow
– 24 hours after@now
.@todayStart
– Current date (00:00:00.000Z).@todayEnd
– Current date (23:59:59.999Z).@monthStart
– Current month (00:00:00.000Z).@monthEnd
– Current month (23:59:59.999Z).@yearStart
– Current year (00:00:00.000Z).@yearEnd
– Current year (23:59:59.999Z).- more...
pbQuery<Post>()
.between('created', '@now', '@yesterday') // Created between now and tomorrow
Basic Operators
Equality Checks
.equal(key, value)
Matches records where key
equals value
.
pbQuery<Post>().equal('author.name', 'Alice'); // name='Alice'
// This is case-sensitive. Use the `:lower` modifier for case-insensitive matching.
pbQuery<Post>().equal('author.name:lower', 'alice'); // name:lower='alice'
.notEqual(key, value)
Matches records where key
is not equal to value
.
pbQuery<Post>().notEqual('author.name', 'Alice'); // name!='Alice'
// This is case-sensitive. Use the `:lower` modifier for case-insensitive matching.
pbQuery<Post>().notEqual('author.name:lower', 'alice'); // name:lower!='alice'
Comparisons
.greaterThan(key, value)
Matches records where key
is greater than value
.
pbQuery<User>().greaterThan('age', 21); // age>21
.greaterThanOrEqual(key, value)
Matches records where key
is greater than or equal to value
.
pbQuery<User>().greaterThanOrEqual('age', 18); // age>=18
.lessThan(key, value)
Matches records where key
is less than value
.
pbQuery<User>().lessThan('age', 50); // age<50
.lessThanOrEqual(key, value)
Matches records where key
is less than or equal to value
.
pbQuery<User>().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.
// Contains
pbQuery<Post>().like('author.name', 'Joh'); // name~'Joh' / name~'%Joh%'
// If not specified, auto-wraps the value in `%` for wildcard matching.
// Starts with
pbQuery<Post>().like('author.name', 'Joh%'); // name~'Joh%'
// Ends with
pbQuery<Post>().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.
// Doesn't contain
pbQuery<Post>().notLike('author.name', 'Joh'); // name!~'Joh' / name!~'%Joh%'
// If not specified, auto-wraps the value in `%` for wildcard matching.
// Doesn't start with
pbQuery<Post>().notLike('author.name', 'Joh%'); // name!~'Joh%'
// Doesn't end with
pbQuery<Post>().notLike('author.name', '%Doe'); // name!~'%Doe'
Combination Operators
Logical Operators
.and()
Combines the previous and the next conditions with an and
logical operator.
pbQuery<User>().equal('name', 'Alice').and().equal('role', 'admin'); // name='Alice' && role='admin'
.or()
Combines the previous and the next conditions with an or
logical operator.
pbQuery<User>().equal('name', 'Alice').or().equal('name', 'Bob'); // name='Alice' || name='Bob'
Grouping
.group(callback)
Creates a logical group.
pbQuery<Post>().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, multiple relation, multiple select, or multiple file.
Return all authors who have published at least one book about "Harry Potter":
pbQuery<Book>().anyLike('books_via_author.title', 'Harry Potter'); // post_via_author.name?~'Harry Potter'
Return all authors who have only published books about "Harry Potter":
pbQuery<Book>().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
.anyEqual(key, value)
Matches records where at least one of the values in the given key
equals value
.
pbQuery<Book>().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<Book>().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
.
pbQuery<Book>().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<Book>().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
.
pbQuery<User>().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
.
pbQuery<User>().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
.
pbQuery<User>().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
.
pbQuery<User>().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.
// Contains
pbQuery<Post>().anyLike('author.name', 'Joh'); // name?~'Joh' / name?~'%Joh%'
// If not specified, auto-wraps the value in `%` for wildcard matching.
// Starts with
pbQuery<Post>().anyLike('author.name', 'Joh%'); // name?~'Joh%'
// Ends with
pbQuery<Post>().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.
// Doesn't contain
pbQuery<Post>().anyNotLike('author.name', 'Joh'); // name?!~'Joh' / name?!~'%Joh%'
// If not specified, auto-wraps the value in `%` for wildcard matching.
// Doesn't start with
pbQuery<Post>().anyNotLike('author.name', 'Joh%'); // name?!~'Joh%'
// Doesn't end with
pbQuery<Post>().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.
// Full-text search
pbQuery<Post>().search(['title', 'content', 'tags', 'author.name', 'author.surname'], 'Football'); // (title~'Football' || content~'Football' || tags~'Football' || author.name~'Football' || author.surname~'Football')
// Contains
pbQuery<User>().search(['name', 'surname'], 'Joh'); // (name~'Joh' || surname~'Joh') / (name~'%Joh%' || surname~'%Joh%')
// If not specified, auto-wraps the value in `%` for wildcard matching.
// Starts with
pbQuery<User>().search(['name', 'surname'], 'Joh%'); // (name~'Joh%' || surname~'Joh%')
// Ends with
pbQuery<User>().search(['name', 'surname'], '%Doe'); // (name~'%Doe' || surname~'%Doe')
.in(key, values)
Matches records where key
is in values
.
pbQuery<Post>().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
.
pbQuery<User>().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
.
pbQuery<User>().between('age', 18, 30); // (age>=18 && age<=30)
pbQuery<User>().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
.
pbQuery<User>().notBetween('age', 18, 30); // (age<18 || age>30)
pbQuery<User>().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.
pbQuery<User>().isNull('name'); // name=''
.isNotNull(key)
Matches records where key
is not null.
pbQuery<User>().isNotNull('name'); // name!=''
Tips and tricks
Typed Query Builders
// query-builders.ts
export const queryUsers = pbQuery<User>;
export const queryPosts = pbQuery<Post>;
// posts.ts
const searchQuery = queryPosts()
.search(['title', 'content', 'tags', 'author'], 'footba')
.build(pb.filter);
// 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.
// Create a base query for sports-related posts
export const querySportsPosts = () => pbQuery<Post>()
.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
- Initial State: When you clone a query, it captures the current state of the query builder, including all conditions and values.
- Independent Instances: Each cloned query is independent, so modifying one does not affect the others.
- Reusability: Cloning is ideal for creating reusable query templates that can be extended with additional conditions.
📜 Real-World Recipes
Paginated Admin Dashboard
const buildAdminQuery = (
searchTerm: string,
options: {
minLogins: number;
roles: string[];
statuses: string[];
}
) => pbQuery<User>()
.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
const productQuery = pbQuery<Product>()
.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
function buildSearchQuery(term: string, user: User) {
const dynamicQuery = pbQuery<Post>().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:
pbQuery<Post>().between('created', new Date('2023-01-01'), new Date());
Performance Tips
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:
// author.info.age
pbQuery<Post, 3>() .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.
@sergio9929/pb-query is maintained by @sergio9929 with ❤️
Found a bug? Open an issue
Want to contribute? Read our guide