npm package: @plumile/filter-query
Description: Typed filter query string parser and serializer for Plumile ecosystem
Author: Olivier Hardy
Latest version: 0.1.87

# @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

```bash
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

```ts
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.

```ts
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 Plumile Router DevTools extension by wiring an instrumentation in development: `createRouter(..., { instrumentations: [createDevtoolsBridgeInstrumentation()] })`.

## Schema Definition

```ts
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:

```ts
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:

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

The inferred `filters` type is roughly:

```ts
{
  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`:

```ts
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

```ts
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

```ts
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