npm package: @nanotiny/json-expression
Description: Query JSON data with compact string expressions. Filter collections, transform values, evaluate conditions, and reuse variables across queries without adding dependencies.
Author: Joehom
Latest version: 0.0.13

# @nanotiny/json-expression

Query JSON data with compact string expressions. Filter collections, transform values, evaluate conditions, and reuse variables across queries without adding dependencies.

> **Beta:** This package is still in beta. Expect the API and query behavior to evolve, and be aware that breaking changes may happen between releases until the project reaches a stable version.

**[Try the live demo →](https://joehom0416.github.io/json-expression-demo/)**

## Install

```bash
npm install @nanotiny/json-expression
```

## Highlights

- Zero runtime dependencies
- Works in Node.js and modern browsers
- Supports filtering, transforms, and boolean evaluation
- Extensible with custom transform functions
- Includes shared query context for variables and aliases
- Ships with TypeScript declarations, ESM, and CommonJS builds

## Quick Start

```ts
import {
  QueryContext,
  evaluate,
  query,
  queryAsArray,
  queryBatch,
} from '@nanotiny/json-expression';

const data = {
  version: '2.0',
  status: 'active',
  companies: [
    { id: 'company1', name: 'Acme Corporation', type: 'corporation', revenue: 15000000 },
    { id: 'company2', name: 'Tech Solutions Ltd', type: 'limited', revenue: 8000000 },
  ],
};

query(data, 'version');
// '2.0'

query(data, 'companies.name | id == "company2"');
// 'Tech Solutions Ltd'

query(data, 'companies.name | type == "corporation" && revenue > 10000000');
// 'Acme Corporation'

query(data, 'companies.name | id == "company1" \\ toUpper()');
// 'ACME CORPORATION'

queryAsArray(data, 'companies');
// [{ id: 'company1', ... }, { id: 'company2', ... }]

evaluate(data, 'status == "active"');
// true

const ctx = new QueryContext();
queryBatch(data, ctx, '$company = "company1"', 'companies.name | id == $company');
// ['', 'Acme Corporation']
```

## Query Syntax

```text
property.path | filter_condition \ transform()
```

Each part is optional after the property path:

- Property path: navigate the JSON structure with `.`
- Filter: narrow array items with `|`
- Transform: modify the result with `\`

When writing queries in JavaScript or TypeScript strings, escape the transform delimiter as `\\`.

## Property Access

```text
version
companies.name
metadata.settings.enableLogging
```

Property access resolves nested values across both objects and arrays.

Core rule:

- `query()` always returns a single resolved value.
- If navigation reaches multiple candidates, `query()` returns the first matched result.
- This is true whether the match comes from plain property navigation or from a filter.
- Use `queryAsArray()` when you want every match instead of the first one.

Examples based on the test suite:

```ts
query(data, 'version');
// '2.0'

query(data, 'metadata.settings.enableLogging');
// true

query(data, 'companies.name');
// 'Acme Corporation'    // first company name

query(data, 'companies.name | id == "company2"');
// 'Tech Solutions Ltd'  // first company that matches the filter

query(data, 'companies.locations.city | locations.$index == 1');
// 'Los Angeles'         // first city produced by the matched path

queryAsArray(data, 'companies');
// returns all companies
```

## Filters

Use filters after `|` to select matching array items.

### Comparison Operators

| Operator | Example |
|----------|---------|
| `==` | `companies.name \| id == "company1"` |
| `!=` | `companies.name \| type != "limited"` |
| `>` `<` `>=` `<=` | `companies.name \| revenue > 10000000` |
| `in` | `companies.name \| id in ["company1", "company3"]` |
| `not in` | `companies.name \| id not in ["company2"]` |
| `like` | `companies.name \| name like "%Corp%"` |
| `not like` | `companies.name \| name not like "%Ltd"` |
| `contains` | `companies.name \| name contains "Solutions"` |
| `startswith` | `companies.name \| name startswith "Tech"` |
| `endswith` | `companies.name \| name endswith "Ltd"` |
| `is null` | `companies.name \| description is null` |
| `is not null` | `companies.name \| description is not null` |
| `between` | `companies.name \| revenue between 8000000 and 16000000` |
| `&&` `\|\|` | `companies.name \| type == "corporation" && isActive == "true"` |

`like` and `not like` use SQL-style wildcard matching: `%` matches any sequence, `_` matches a single character, and other characters are treated literally.

### Combine Conditions

Use `&&` (AND) or `||` (OR) to combine multiple conditions. `&&` has higher precedence than `||`, so conditions are evaluated as OR-separated groups of AND expressions.

```ts
// AND — both conditions must match
query(data, 'companies.name | type == "corporation" && isActive == "true"');
// 'Acme Corporation'

// OR — either condition can match (first matching item is returned)
query(data, 'companies.name | type == "limited" || type == "corporation"');
// 'Acme Corporation'

// Mix AND and OR (`&&` binds tighter than `||`)
query(data, 'companies.name | type == "corporation" || isActive == "true" && revenue < 1000');
// 'Acme Corporation'
```

### Explicit Boolean Grouping with `[]`

Use `[` and `]` when you want to force a particular boolean grouping.

```ts
query(data, 'companies.name | [type == "limited" || type == "corporation"] && isActive == "false"');
// 'Global Innovations Corp'

query(data, 'companies.name | [id == (employees.companyId | id == "emp1") || id == "company2"]');
// 'Acme Corporation'

evaluate(data, '[version == "1.0" || status == "active"] && metadata.settings.maxRetries == 5');
// true
```

> **Notes:**
> - Quoted string values can include literal `&&` or `||` (for example `name == "foo && bar"`). Those sequences are only treated as boolean operators when they appear outside quoted values.
> - Using multiple `|` delimiters in one query (e.g. `path | cond1 | cond2`) throws an `Error` at parse time. Use `&&` or `||` inside a single filter instead.
> - Parentheses `()` are reserved for cross-reference sub-queries, not boolean grouping.
> - Square brackets `[]` are reserved for explicit boolean grouping.
> - `||` groups must stay within a single array level in `query()` filters. For example, `id == "company1" || type == "limited"` is fine, but `[id == "company1" || locations.$index == 1]` throws because it mixes company-level and location-level conditions inside one OR group.

### Filter by Position with `$index`

The library injects `$index` while iterating arrays.

```text
companies.name | $index == 1
companies.locations.city | locations.$index == 1
```

### Cross-Reference Another Query

Use parentheses to resolve another query and compare against its result.

```text
companies.name | id == (employees.companyId | id == "emp1")
```

## Variables and Aliases

For reusable queries, create a `QueryContext` and pass it to `query()` or `queryBatch()`.

### Variables

Assign a value once and reference it later with a `$` prefix.

```ts
import { QueryContext, query } from '@nanotiny/json-expression';

const ctx = new QueryContext();

query(data, '$companyId = "company1"', ctx);
query(data, 'companies.name | id == $companyId', ctx);
// 'Acme Corporation'
```

Notes:

- Assignments return an empty string because they update the context instead of selecting data.
- Variables work well with `queryBatch()` when several queries need shared state.

### Aliases

Aliases shorten long paths and make complex expressions easier to read.

```ts
import { QueryContext, query } from '@nanotiny/json-expression';

const ctx = new QueryContext();

query(data, 'companies as comp | comp.id == "company2"', ctx);
query(data, 'comp.name', ctx);
// 'Tech Solutions Ltd'
```

Use aliases when the same root path appears repeatedly in filters or transforms.

## Transforms

Apply transforms after `\`. In code, write them as `\\` inside strings.

### String Transforms

| Transform | Example | Result |
|-----------|---------|--------|
| `toUpper()` | `name \ toUpper()` | `"ACME"` |
| `toLower()` | `name \ toLower()` | `"acme"` |
| `substring(start, length)` | `name \ substring(0, 4)` | `"Acme"` |
| `split(delimiter)` | `name \ split(" ")` | `["Acme", "Corporation"]` |
| `replace(old, new)` | `name \ replace("Corp", "Co")` | `"Acme Co"` |
| `trim()` | `desc \ trim()` | trimmed string |
| `padLeft(width, char)` | `id \ padLeft(12, "0")` | `"0000company1"` |
| `padRight(width, char)` | `id \ padRight(12, ".")` | `"company1...."` |

### Numeric Transforms

| Transform | Example | Result |
|-----------|---------|--------|
| `round(decimals)` | `price \ round(1)` | `3128431.2` |
| `ceiling()` | `price \ ceiling()` | `3128432` |
| `floor()` | `price \ floor()` | `3128431` |
| `abs()` | `value \ abs()` | absolute value |
| `toNumFormat(format)` | `version \ toNumFormat("00.0")` | `"02.0"` |

### Array Transforms

| Transform | Example | Description |
|-----------|---------|-------------|
| `count()` | `companies \ count()` | Number of elements |
| `min()` | `values \ min()` | Minimum numeric value |
| `max()` | `values \ max()` | Maximum numeric value |
| `sum()` | `values \ sum()` | Sum of numeric values |
| `average()` | `values \ average()` | Average of numeric values |
| `slice(start, length)` | `companies \ slice(0, 2)` | Sub-array |
| `sort(prop, dir)` | `companies \ sort("name", "asc")` | Sorted copy |

### Conversion and Conditional Transforms

| Transform | Example | Result |
|-----------|---------|--------|
| `toRoman()` | `count \ toRoman()` | `"III"` |
| `numToText()` | `count \ numToText()` | `"Three"` |
| `toDateFormat(fmt)` | `createdAt \ toDateFormat(yyyy-MM-dd)` | `"2024-04-16"` |
| `if(cond, true, false)` | `price \ if("> 3000000", "expensive", "affordable")` | `"expensive"` |
| `ifBlank(default)` | `desc \ ifBlank("N/A")` | `"N/A"` if blank |

### Chain Transforms

```ts
query(data, 'companies.name | id == "company1" \\ toUpper() \\ substring(0, 4)');
// 'ACME'

query(data, 'companies \\ count() \\ toRoman()');
// 'II'
```

## Custom Transforms

You can register your own transform functions to extend the built-in set. Custom transforms work with the same `\transformName(args)` syntax.

### Global Registration

Use `registerTransform()` to make a custom transform available in all queries.

```ts
import { query, registerTransform, unregisterTransform } from '@nanotiny/json-expression';

// Register a custom transform
registerTransform('reverse', (value) => {
  return String(value ?? '').split('').reverse().join('');
});

query(data, 'status\\reverse()');
// 'evitca'

// Remove it when no longer needed
unregisterTransform('reverse');
```

### Context-Scoped Registration

Register transforms on a `QueryContext` for per-query or scoped usage.

```ts
import { query, QueryContext } from '@nanotiny/json-expression';

const ctx = new QueryContext();
ctx.registerTransform('double', (value) => {
  return String(value).repeat(2);
});

query(data, 'status\\double()', ctx);
// 'activeactive'
```

### Custom Transform Function Signature

```ts
type CustomTransformFn = (
  value: unknown,          // current pipeline value (single element)
  parameters: string[],    // parsed parameters from the expression
  originalArray: unknown,  // the raw input before array-to-single extraction
) => unknown;
```

The `originalArray` parameter gives access to the full array for aggregate transforms:

```ts
registerTransform('joinwith', (value, parameters, originalArray) => {
  const separator = parameters[0] ?? ', ';
  const source = Array.isArray(originalArray) ? originalArray : [value];
  return source.join(separator);
});

query(data, 'metadata.tags\\joinwith(-)');
// 'production-enterprise-verified'
```

### Override Built-in Transforms

Custom transforms take priority over built-in ones with the same name. Context-level transforms take priority over global ones.

```ts
// Override built-in toupper globally
registerTransform('toupper', (value) => `CUSTOM:${String(value).toUpperCase()}`);

query(data, 'status\\toupper()');
// 'CUSTOM:ACTIVE'

// Unregister to restore built-in behavior
unregisterTransform('toupper');

query(data, 'status\\toupper()');
// 'ACTIVE'
```

### Chain Custom and Built-in Transforms

Custom transforms chain seamlessly with built-in ones.

```ts
registerTransform('exclaim', (value) => `${value}!`);

query(data, 'status\\toupper()\\exclaim()');
// 'ACTIVE!'
```

## API Reference

### `query(data, queryStr, context?)`

Returns the first matching value.

```ts
query(data, 'companies.name | id == "company1"');
// 'Acme Corporation'
```

Use `context` when you need variables or aliases to persist across calls.

### `queryAsArray(data, queryStr)`

Returns every matching value as an array.

```ts
queryAsArray(data, 'companies');
// [{ id: 'company1', ... }, { id: 'company2', ... }]
```

### `evaluate(data, condition)`

Evaluates a condition against the input data and returns a boolean.

```ts
evaluate(data, 'version == "2.0"');
// true

evaluate(data, 'status == "inactive"');
// false
```

```ts
evaluate(data, 'version == "2.0"') && evaluate(data, 'status == "active"');
// true
```

### `queryBatch(data, ...queries)`

Executes multiple queries and returns the results in order.

```ts
queryBatch(data, 'version', 'status', 'companies.name | id == "company1"');
// ['2.0', 'active', 'Acme Corporation']
```

### `queryBatch(data, context, ...queries)`

Executes multiple queries with a shared `QueryContext`.

```ts
const ctx = new QueryContext();

queryBatch(
  data,
  ctx,
  '$company = "company2"',
  'companies.name | id == $company',
);
// ['', 'Tech Solutions Ltd']
```

### `registerTransform(name, fn)`

Registers a custom transform function globally.

```ts
registerTransform('reverse', (value, parameters, originalArray) => {
  return String(value ?? '').split('').reverse().join('');
});
```

### `unregisterTransform(name)`

Removes a globally registered custom transform, restoring the built-in if one exists.

```ts
unregisterTransform('reverse');
```

### `QueryContext`

Stores variables, aliases, and custom transforms across multiple queries.

```ts
const ctx = new QueryContext();

query(data, '$id = "company1"', ctx);
query(data, 'companies.name | id == $id', ctx);
// 'Acme Corporation'

// Register a scoped custom transform
ctx.registerTransform('double', (value) => String(value).repeat(2));
query(data, 'status\\double()', ctx);
// 'activeactive'
```

## Edge Cases

### Path Not Found

When a key does not exist anywhere in the path, `query()` returns `undefined`. Use the `ifBlank()` transform or the `??` operator to supply a default.

| Scenario | Returns |
|---|---|
| Root key missing (`query(data, 'noSuchKey')`) | `undefined` |
| Nested key missing (`query(data, 'companies.noSuchField')`) | `undefined` |
| Filter with no matching item | `undefined` |
| Key exists, value is `null` or blank | `''` (empty string) |

```ts
query(data, 'nonExistentKey');                         // undefined
query(data, 'companies.nonExistentField');             // undefined
query(data, 'companies.name | id == "ghost"');         // undefined

query(data, 'companies.description | id == "co2"');    // '' (null in data)
query(data, 'companies.description | id == "co2" \\ ifBlank("N/A")'); // 'N/A'

// Safe default in JavaScript
const value = (query(data, 'someKey') ?? 'default') as string;
```

### Invalid Filter Syntax

A filter expression that uses an unrecognised operator, is structurally malformed, or contains a stray `|` throws an `Error` at parse time.

```ts
try {
  query(data, 'companies.name | id ?? "company1"');  // unknown operator ??
} catch (e) {
  // Error: Invalid filter expression: id ?? "company1"
}

try {
  query(data, 'companies.name | id');  // missing operator and right operand
} catch (e) {
  // Error: Invalid filter expression: id
}

try {
  // Multiple | delimiters — use && or || inside a single filter instead
  query(data, 'companies.name | type == "corporation" | isActive == "true"');
} catch (e) {
  // Error: Invalid filter expression: "..." contains a stray "|".
  //        Use "||" for logical OR, or split into separate query() calls.
}
```

Always validate user-supplied query strings with a `try/catch` when the input comes from external sources.

### Type Mismatch in Comparisons

All operand values are coerced to strings before comparison. There is no runtime type error — mismatched types produce a silent non-match instead.

| Operator | Behaviour on type mismatch |
|---|---|
| `==` / `!=` | String equality after coercion — `42` becomes `"42"`, `true` becomes `"true"` |
| `>` `<` `>=` `<=` | Uses `parseFloat` — a non-numeric value becomes `NaN` and the comparison returns `false` |
| `between` | Uses `parseFloat` on both bounds — non-numeric silently returns `false` |
| `contains` / `startswith` / `endswith` / `like` | Applied to the stringified value — always safe |

For `like`, matching is SQL-style rather than regex-based: `%` matches any sequence, `_` matches a single character, and characters such as `.`, `(`, `)` and `[` keep their literal meaning.

```ts
// Numeric field vs unquoted number → both stringify to "120000" → matches
query(data, 'employees.name | salary == 120000');   // 'John Doe'

// Numeric field vs quoted string → quotes stripped, string-equal → still matches
query(data, 'employees.name | salary == "120000"'); // 'John Doe'

// Ordering: non-numeric value on numeric operator → NaN → no match, no error
query(data, 'employees.name | name > 100');         // undefined (silent)

// Boolean field: stored as boolean, coerced to "true"/"false" for comparison
query(data, 'companies.name | isActive == true');   // 'Acme Corporation'
query(data, 'companies.name | isActive == "true"'); // 'Acme Corporation' (quotes stripped)
```

## Compatibility

- Node.js 14 or later
- Modern browsers with ES2020 support
- ESM and CommonJS builds
- TypeScript declarations included

## License

MIT