npm.io
0.1.191 • Published yesterday

@plumile/filter-query

Licence
MIT
Version
0.1.191
Deps
1
Size
233 kB
Vulns
0
Weekly
426

@plumile/filter-query

Typed, schema-driven filter query string parser / serializer with immutable helpers and strong TypeScript inference. Zero runtime dependencies.

Status

Recommended for external use. This package is intended to work as a standalone URL filter/query helper and as the filter model behind @plumile/router.

Why?

Applications often encode complex filter state (numbers, ranges, multi-selects, text searches) into a URL query string. Ad-hoc solutions easily drift: inconsistent operator names, ambiguous serialization, lost type safety, brittle parsing, and noisy re-renders. @plumile/filter-query gives you:

  • A mandatory schema (single source of truth) declaring fields and allowed operators.
  • Deterministic, canonical string generation (stable ordering for cache keys & SSR).
  • Strong TypeScript inference for the shape of filters (no manual typings).
  • Non-blocking diagnostics instead of exceptions (you decide how to surface issues).
  • Immutable, reference-stable mutation helpers (minimize React renders / memo churn).
  • Simple, explicit operator semantics (predictable merging & precedence rules).

Installation

npm install @plumile/filter-query

Module Entry Points

  • @plumile/filter-query: ESM bundle exporting parsing, serialization, and helpers.
  • @plumile/filter-query/lib/esm/*: deep imports for specific helpers when needed.
  • @plumile/filter-query/lib/types/*: TypeScript declarations consumed automatically by the types field.

The package is ESM-only; ensure your bundler supports native ESM (Vite, Next.js, webpack 5+ with type: 'module', etc.).

Core Concepts

Concept Description
Schema Object produced by defineSchema({ field: numberField(), ... }). Required.
Field Descriptor numberField() or stringField() optionally with a custom operator whitelist.
Filters Object Parsed result shape inferred from the schema (operators become optional keys).
Diagnostics Array of non-blocking issues (unknown field/op, invalid value, etc.).
Mutation Helpers Pure functions returning new filter objects (or the same reference if no change).

Supported Operators

Category Operators Syntax Notes
Numeric comparison gt, gte, lt, lte, eq, neq price.gt=10 Last write wins per operator. price=5 is implicit eq.
Textual comparison eq, neq title.eq=foo Works for strings too; title=foo is implicit eq.
Text search contains, sw, ew title.contains=foo%20bar Raw values URL-decoded.
Range between price.between=10,100 Only first valid occurrence kept; duplicates yield DuplicateBetween diagnostic.
Inclusion lists in id.in=1,2,3 Multi-occurrences merge: id.in=1,2&id.in=3 -> [1,2,3].
Exclusion lists nin id.nin=4,5&... Same merging logic as in.
Value Parsing Rules
  • Number field: attempts Number(), rejects NaN / Infinity.
  • String field: raw decoded string (empty string allowed, but not produced by numeric parse).
  • Boolean field: accepts true, false, 1, 0 (case-insensitive).
  • Lists: split by comma; invalid members emit InvalidValue and are skipped; empty result discards whole operator.
  • Between: must have exactly 2 comma-separated values; otherwise InvalidArity.
  • Duplicate between: first valid stored, later ones produce DuplicateBetween.

Quick Start

import {
  booleanField,
  defineSchema,
  enumField,
  numberField,
  stringField,
  parse,
  stringify,
  setFilter,
} from '@plumile/filter-query';

const schema = defineSchema({
  price: numberField(),
  title: stringField(),
  active: booleanField(),
  status: enumField(['OPEN', 'CLOSED']),
});

// Parse URL search (leading '?' optional)
const { filters, diagnostics } = parse(
  'price.gt=10&title.contains=foo%20bar',
  schema,
);
// filters.price?.gt === 10, filters.title?.contains === 'foo bar'
// diagnostics: []

// Apply immutable mutation
const updated = setFilter(filters, schema, 'price', 'between', [10, 100]);

// Serialize back (canonical ordering: schema field order, then operator order)
const qs = stringify(updated, schema);
// => price.gt=10&price.between=10,100&title.contains=foo%20bar

Using with @plumile/router

Attach the schema to a route and rely on router hooks for strongly typed filters.

import { defineSchema, numberField, stringField } from '@plumile/filter-query';
import { r, useFilters, useNavigate } from '@plumile/router';

export const productFilters = defineSchema({
  page: numberField(),
  title: stringField(['contains']),
});

export const routes = [
  r({
    path: '/products',
    querySchema: productFilters,
    prepare: ({ filters }) => ({ page: filters.page?.eq ?? 1 }),
    render: () => null,
  }),
];

function ProductsList() {
  const [filters, { set, clear }] = useFilters(productFilters);
  const navigate = useNavigate();

  const page = filters.page?.eq ?? 1;
  const goToPage = (next: number) =>
    navigate({ pathname: '/products', filters: { page: { eq: next } } });

  return null;
}
  • useFilters(productFilters) returns typed filter state and immutable helpers.
  • useFilterDiagnostics() surfaces parsing issues (unknown field/operator) for UI or logging.
  • Enable the Kronex Router DevTools extension by wiring an instrumentation in development: createRouter(..., { instrumentations: [createDevtoolsBridgeInstrumentation()] }).

Schema Definition

const schema = defineSchema({
  price: numberField(), // full numeric operator set
  title: stringField(['contains', 'sw']), // restrict to subset
  active: booleanField(['eq', 'neq']),
  status: enumField(['OPEN', 'CLOSED'], ['eq', 'in']),
});

Both helpers accept an optional operator list to whitelist allowed operators (anything not listed becomes UnknownOperator if present in input).

Operator Defaults
  • numberField() default: gt,gte,lt,lte,eq,neq,between,in,nin
  • stringField() default: contains,sw,ew,eq,neq,in,nin
  • booleanField() default: eq,neq,in,nin
  • enumField() default: eq,neq,in,nin

Diagnostics

Returned shape:

interface DiagnosticBase {
  kind: string;
  field?: string;
  operator?: string;
  detail?: string;
}
// Kinds: 'UnknownField' | 'UnknownOperator' | 'InvalidValue' | 'InvalidArity' | 'DuplicateBetween' | 'DecodeError'

Parsing never throws for content errors; all issues are accumulated. You decide how to surface them (log panel, dev overlay, UI badges, etc.).

Filters Object & Type Inference

From a schema:

const schema = defineSchema({ price: numberField(), title: stringField() });

The inferred filters type is roughly:

{
  price?: {
    gt?: number; gte?: number; lt?: number; lte?: number; eq?: number; neq?: number;
    between?: readonly [number, number];
    in?: readonly number[]; nin?: readonly number[];
  };
  title?: {
    contains?: string; sw?: string; ew?: string; eq?: string; neq?: string;
    in?: readonly string[]; nin?: readonly string[];
  };
}

Everything is optional so you can build partial filters progressively.

Custom Field Parsing

You can enforce custom parsing/serialisation rules via customField:

import { customField, defineSchema } from '@plumile/filter-query';

const schema = defineSchema({
  since: customField<Date>({
    operators: ['eq'],
    parse(raw) {
      const parsed = new Date(raw);
      if (Number.isNaN(parsed.getTime())) {
        return undefined;
      }
      return parsed;
    },
    serialize(value) {
      return value.toISOString();
    },
  }),
});

Mutation Helpers

setFilter(filters, schema, 'price', 'gt', 10); // add/update value
setFilter(filters, schema, 'price', 'gt', undefined); // remove operator key
removeFilter(filters, 'price'); // drop entire field
removeFilter(filters, 'price', 'gt'); // drop one operator
mergeFilters(base, patch); // shallow merge per field
Reference Stability
  • If a mutation results in no semantic change -> the same object reference is returned.
  • Enables cheap memoization (useMemo, React context selectors, etc.).

Serialization Rules

  1. Field iteration order = schema object key order (stable in modern JS for own string keys).
  2. Operator order = descriptor.operators order.
  3. Operators with no value / empty arrays are skipped.
  4. List operators keep insertion order across multi-occurrences.
  5. Output omits leading ? (caller decides how to prefix).
  6. Implicit equality: when an eq value exists it is serialized as field=value (no .eq).

Edge Cases & Examples

Input Result Diagnostics
price.gt=abc filters.price?.gt absent InvalidValue
price=10 filters.price?.eq === 10 none
price.between=1,2,3 none stored InvalidArity
price.between=1,5&price.between=2,6 [1,5] DuplicateBetween
price.in= ignored none (empty split produces no valid values)
unknown.gt=5 ignored UnknownField
price.xyz=5 ignored UnknownOperator
title.contains=x%ZZ ignored DecodeError + maybe others

Custom Operator Subsets

const minimal = defineSchema({ price: numberField(['gt', 'lt']) });
// parse('price.eq=5', minimal) -> diagnostics: UnknownOperator

Performance Notes

  • Parsing is single pass over pairs; only allocates for: decoded strings, filter field objects when first used, diagnostics entries.
  • Mutations avoid cloning unchanged branches (shallow one-level cloning only when something changes).

FAQ

Q: Why not support arbitrary operator names? To keep type inference precise and predictable. Add new core operators via a PR if they are broadly useful.

Q: How does implicit equality work? Supplying field=value without an explicit operator is parsed as field.eq=value internally. Serialization emits the implicit short form again for stability.

Q: How does this integrate with @plumile/router? The router consumes the same schema (as querySchema) and builds a unified filters object accessible via its hooks. Equality remains implicit in the URL (page=2 <=> page.eq=2 internally).

Q: How do I clear everything? Just use an empty object {} or parse an empty string and replace your state reference.

Q: Does order of repeated list operators matter? Yes, items are appended in encounter order, preserving user intent (e.g. prioritized IDs).

Validation Notes

  • parsing, serialization, diagnostics, mutation helpers, and type inference are covered by unit tests and type tests in the package workspace

Limitations

  • operator vocabulary is intentionally fixed to preserve strong inference
  • schema-driven design is mandatory; arbitrary untyped filters are not the goal
  • package is ESM-only

License

MIT

Keywords