Build type-safe PocketBase queries with the power of TypeScript.
Flexible and strongly-typed, with useful helpers to simplify the querying process.
- 💬 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.
# npm
npm install @sergio9929/pb-query
# pnpm
pnpm add @sergio9929/pb-query
# yarn
yarn add @sergio9929/pb-query
// 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.
// 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);
});
- ✨ Why pb-query?
- 🧠 Core Concepts
- 🔧 Basic Operators
- 🧩 Combination Operators
- 🛠️ Multiple Operators
- ⚡ Helper Operators
- 💡 Tips and Tricks
- 📜 Real-World Recipes
- 🚨 Troubleshooting
- 🙏 Credits
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.
Documentation directly in your IDE.
Leveraging the power of TypeScript, we provide suggestions based on your schema.
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();
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%'"
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'
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
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'
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'
Matches records where key
is greater than value
.
pbQuery<User>().greaterThan('age', 21); // age>21
Matches records where key
is greater than or equal to value
.
pbQuery<User>().greaterThanOrEqual('age', 18); // age>=18
Matches records where key
is less than value
.
pbQuery<User>().lessThan('age', 50); // age<50
Matches records where key
is less than or equal to value
.
pbQuery<User>().lessThanOrEqual('age', 65); // age<=65
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'
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'
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'
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'
Creates a logical group.
pbQuery<Post>().group((q) => q.equal('status', 'active').or().equal('status', 'inactive')); // (status~'active' || status~'inactive')
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
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'
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'
Matches records where at least one of the values in the given key
is greater than value
.
pbQuery<User>().anyGreaterThan('age', 21); // age?>21
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
Matches records where at least one of the values in the given key
is less than value
.
pbQuery<User>().anyLessThan('age', 50); // age?<50
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
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'
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'
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')
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')
Matches records where key
is not in values
.
pbQuery<User>().notIn('age', [18, 21, 30]); // (age!=18 && age!=21 && age!=30)
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')
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')
Matches records where key
is null.
pbQuery<User>().isNull('name'); // name=''
Matches records where key
is not null.
pbQuery<User>().isNotNull('name'); // name!=''
// 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);
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')
- 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.
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);
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);
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);
Problem: Date comparisons not working
Fix: Always use Date objects:
pbQuery<Post>().between('created', new Date('2023-01-01'), new Date());
-
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'
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