@sergio9929/pb-query
TypeScript icon, indicating that this package has built-in type declarations

0.2.9 • Public • Published

@sergio9929/pb-query

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 TypeScript

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

Learn more

// 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?

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

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

Field name suggestions

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

  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

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

  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:

    // 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

Package Sidebar

Install

npm i @sergio9929/pb-query

Weekly Downloads

9

Version

0.2.9

License

UNLICENSED

Unpacked Size

77.9 kB

Total Files

6

Last publish

Collaborators

  • sergio9929