Skip to content

crisp-oquentSpatie query builder, in TypeScript

Zero dependencies. Just fetch. Eloquent-style fluent API that speaks Spatie laravel-query-builder's URL contract — including JSON:API Filter Groups.

crisp-oquent CO₂ logo
@bir-tan/crisp-oquent · v2.1.0

Spatie query builder,
in TypeScript.

Zero dependencies. Just fetch. An Eloquent-style fluent API that speaks Spatie laravel-query-builder's URL contract — including JSON:API Filter Groups (v7.3.0).

0 dependencies·20.8 kB tarball·41/41 tests·ESM · TS · Apache-2.0

Vanilla fetch vs. crisp-oquent

The same query against a Spatie-powered Laravel API:

ts
const url = new URL('/users', 'https://api.example.com');
url.searchParams.set('filter[status]', 'active');
url.searchParams.set('filter[id]', [1, 2, 3].join(','));
url.searchParams.set('filter[salary]', '>3000');
url.searchParams.set('sort', '-created_at');
url.searchParams.set('include', 'posts,profile');
url.searchParams.set('page', '2');
url.searchParams.set('per_page', '25');

const res = await fetch(url, {
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${token}`,
  },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const items = json.data.map((row) => /* hand-roll mapper */);
ts
import { FilterOperator } from '@bir-tan/crisp-oquent';

const page = await User.crispy()
  .filter('status', 'active')
  .filter('id', [1, 2, 3])
  .where('salary', FilterOperator.GREATER_THAN, 3000)
  .sortByDesc('created_at')
  .include('posts', 'profile')
  .paginate(2, 25);

page.items;        // User[]
page.hasMorePages();
page.links.next;

Built on Spatie's URL contract

crisp-oquent is the JavaScript companion to Spatie laravel-query-builder, the de-facto query builder for Laravel APIs.

Backend defines what's allowed:

php
// Laravel + Spatie
QueryBuilder::for(User::class)
    ->allowedFilters(
        AllowedFilter::partial('status'),
        AllowedFilter::exact('id'),
        AllowedFilter::operator('salary', FilterOperator::DYNAMIC),
        AllowedFilter::groupOr('q', [
            AllowedFilter::partial('name'),
            AllowedFilter::partial('full_name'),
        ]),
    )
    ->allowedSorts('created_at', 'name')
    ->allowedIncludes('posts', 'profile')
    ->paginate(25);

Frontend speaks that contract verbatim:

ts
// crisp-oquent
await User.crispy()
  .filter('status', 'active')
  .where('salary', FilterOperator.GREATER_THAN, 3000)
  .filterGroup('q', 'John')        // → backend OR (name LIKE %John% OR full_name LIKE %John%)
  .sortByDesc('created_at')
  .include('posts', 'profile')
  .paginate(2, 25);

Quick start (3 steps)

bash
npm install @bir-tan/crisp-oquent
ts
// 1. Configure once
import { CrispOquentConfig } from '@bir-tan/crisp-oquent';
CrispOquentConfig.initialize({ baseUri: 'https://api.example.com' });

// 2. Define a model
import { Model } from '@bir-tan/crisp-oquent';
class User extends Model {
  static override uri = '/users';
  declare id?: number;
  declare name?: string;
}

// 3. Query
const users = await User.crispy().filter('status', 'active').get();

Full guide →