npm package: @infitx/match
Description: Object pattern matching utility
Author: Kalin Krustev
Latest version: 1.5.4

# Match Function

A flexible matching utility that compares values with advanced semantic rules,
supporting nested structures, arrays, type coercion, and various matching strategies.

## Installation

```javascript
const match = require('./match');
```

## Basic Usage

```javascript
const match = require('./match');

// Exact match
match({ a: 1 }, { a: 1 }); // true
match({ a: 1 }, { a: 2 }); // false

// Partial object match
match({ a: 1, b: 2 }, { a: 1 }); // true
```

## Referencing Fact Values with `$ref`

The `$ref` feature allows rules to reference values from other parts of the fact using JSON Pointer syntax. This is useful for dynamic comparisons where the expected value comes from the fact itself.

```javascript
// Reference another property in the fact
match(
  { expectedStatus: 'active', order: { status: 'active' } },
  { order: { status: { $ref: '#/expectedStatus' } } }
); // true - order.status matches expectedStatus

// Use $ref in min/max range conditions
match(
  { offer: { dateCreated: '2024-01-01' }, order: { dateCreated: '2024-02-01' } },
  { order: { dateCreated: { min: { $ref: '#/offer/dateCreated' } } } }
); // true - order date is after offer date

// Multiple $ref in same rule
match(
  { source: 'Alice', destination: 'Bob', transfer: { from: 'Alice', to: 'Bob' } },
  { transfer: { from: { $ref: '#/source' }, to: { $ref: '#/destination' } } }
); // true - transfer from/to matches source/destination

// $ref with nested paths
match(
  { config: { pricing: { minPrice: 100 } }, order: { price: 150 } },
  { order: { price: { min: { $ref: '#/config/pricing/minPrice' } } } }
); // true - order price is above configured minimum

// Missing properties resolve to undefined
match(
  { order: { dateCreated: '2024-02-01' } },
  { order: { dateCreated: { min: { $ref: '#/offer/dateCreated' } } } }
); // true - missing reference is treated as no constraint

// $ref with type coercion
match(
  { targetAmount: '500', order: { amount: 500 } },
  { order: { amount: { $ref: '#/targetAmount' } } }
); // true - string '500' is coerced to number 500

// $ref with arrays (any-of semantics apply)
match(
  { allowedTag: 'vip', order: { tags: ['vip', 'premium'] } },
  { order: { tags: { $ref: '#/allowedTag' } } }
); // true - at least one tag matches the reference
```

### JSON Pointer Syntax

The `$ref` value uses JSON Pointer syntax (RFC 6901):
- Always starts with `#/` to indicate the root of the fact object
- Properties are separated by `/` 
- Examples:
  - `#/offer/dateCreated` → `fact.offer.dateCreated`
  - `#/config/pricing/minPrice` → `fact.config.pricing.minPrice`
  - `#/expectedStatus` → `fact.expectedStatus`

### When to Use `$ref`

Use `$ref` when you need to:
- Compare related values within the same fact
- Implement dynamic constraints based on other properties
- Reference configuration values from the fact
- Create rules that adapt to the data being matched

```

## Matching Against Nested Structures

The match function recursively compares nested objects:

```javascript
// Nested object matching
match(
  { user: { name: 'Alice', age: 30 } },
  { user: { name: 'Alice' } }
); // true - partial match on nested object

match(
  { user: { name: 'Alice', age: 30 } },
  { user: { name: 'Bob' } }
); // false - name doesn't match

// Deep nesting
match(
  { a: { b: { c: { d: 'value' } } } },
  { a: { b: { c: { d: 'value' } } } }
); // true

match(
  { a: { b: { c: { d: 'value', e: 'extra' } } } },
  { a: { b: { c: { d: 'value' } } } }
); // true - extra properties are ignored
```

## Array Matching (Any-Of Semantics)

Arrays implement "any of" semantics - at least one element must match. The
behavior varies depending on whether arrays appear in the value, the condition,
or both.

### Array in Condition (Rule)

When the condition is an array, the value must match **at least one** element
in the array:

```javascript
// Scalar value against array condition
match('hello', ['hello', 'world']); // true - matches first element
match('world', ['hello', 'world']); // true - matches second element
match('goodbye', ['hello', 'world']); // false - matches none

// With numbers
match(5, [1, 5, 10]); // true
match(7, [1, 5, 10]); // false

// With booleans
match(true, [true, false]); // true
match(false, [true]); // false

// In nested structures - scalar value against array condition
match(
  { status: 'active' },
  { status: ['active', 'pending', 'processing'] }
); // true - status matches one of the allowed values

match(
  { status: 'inactive' },
  { status: ['active', 'pending'] }
); // false - status doesn't match any allowed value
```

### Array in Value

When the value is an array, **at least one element** must match the condition:

```javascript
// Array value against scalar condition
match(['apple', 'banana'], 'apple'); // true - first element matches
match(['apple', 'banana'], 'banana'); // true - second element matches
match(['apple', 'banana'], 'orange'); // false - no element matches

// With numbers
match([1, 2, 3], 2); // true
match([1, 2, 3], 5); // false

// In nested structures - array value against scalar condition
match(
  { tags: ['javascript', 'node', 'async'] },
  { tags: 'javascript' }
); // true - at least one tag matches

match(
  { tags: ['python', 'django'] },
  { tags: 'javascript' }
); // false - no tag matches

// Array value against function condition
match(
  { scores: [85, 90, 78] },
  { scores: (score) => score >= 80 }
); // true - at least one score is >= 80

match(
  { scores: [65, 70, 75] },
  { scores: (score) => score >= 80 }
); // false - no score is >= 80
```

### Array in Both Value and Condition

When both are arrays, a match occurs if **any value element matches any
condition element**:

```javascript
// Both arrays - cartesian "any of any" matching
match(['red', 'blue'], ['blue', 'green']); // true - 'blue' appears in both
match(['red', 'yellow'], ['blue', 'green']); // false - no common elements

match([1, 2, 3], [3, 4, 5]); // true - '3' is in both
match([1, 2], [3, 4]); // false - no overlap

// In nested structures
match(
  { tags: ['javascript', 'node'] },
  { tags: ['node', 'async', 'backend'] }
); // true - 'node' is in both arrays

match(
  { tags: ['python', 'django'] },
  { tags: ['javascript', 'react'] }
); // false - no common tags

// Complex: array value against array of conditions (including objects)
match(
  { priority: [1, 2, 3] },
  { priority: [{ min: 2, max: 5 }, 10] }
); // true - elements 2 and 3 match the range { min: 2, max: 5 }

match(
  { priority: [1] },
  { priority: [{ min: 2, max: 5 }, 10] }
); // false - 1 doesn't match range or 10
```

### Array Value Against Object Condition

When an array value is matched against an object condition (like a range or
complex object), each element is tested against the condition:

```javascript
// Array value against range condition
match(
  [1, 5, 10],
  { min: 3, max: 7 }
); // true - element '5' falls within the range

match(
  [1, 2],
  { min: 3, max: 7 }
); // false - no element falls within the range

// In nested structures
match(
  { scores: [45, 67, 89, 92] },
  { scores: { min: 80 } }
); // true - elements 89 and 92 are >= 80

match(
  { scores: [45, 67, 75] },
  { scores: { min: 80 } }
); // false - no score is >= 80

// Array value against regex condition
match(
  { emails: ['admin@test.com', 'invalid', 'user@test.com'] },
  { emails: /@test\.com$/ }
); // true - at least one email matches the pattern

match(
  { emails: ['invalid1', 'invalid2'] },
  { emails: /@test\.com$/ }
); // false - no email matches
```

### Combining Arrays with Null

Arrays can include `null` to make conditions optional:

```javascript
// Array with null - matches if property is missing OR matches other values
match(
  { status: 'active' },
  { status: [null, 'active'] }
); // true - matches 'active'

match(
  { name: 'Alice' },
  { status: [null, 'active'] }
); // true - 'status' is missing, matches null

match(
  { status: false },
  { status: [null, 'active'] }
); // false - false is neither null nor 'active'

match(
  { status: undefined },
  { status: [null, 'active'] }
); // true - undefined treated as null

// Nested with arrays and null
match(
  { user: { role: 'admin' } },
  { user: { role: [null, 'admin', 'moderator'] } }
); // true - role matches 'admin'

match(
  { user: { name: 'Alice' } },
  { user: { role: [null, 'admin'], name: 'Alice' } }
); // true - role is missing (matches null), name matches
```

### Array Behavior Summary

| Value Type | Condition Type             | Behavior                                      |
| ---------- | -------------------------- | --------------------------------------------- |
| Scalar     | Array                      | Value must match at least one array element   |
| Array      | Scalar                     | At least one value must match the scalar      |
| Array      | Array                      | At least one value must match one condition   |
| Array      | Object (range/complex)     | At least one value must match the condition   |
| Array      | Function                   | At least one value must satisfy the function  |

## Matching Against Non-Existing Properties

The match function has special handling for `null` values to match against
non-existing properties:

```javascript
// null matches undefined/missing properties
match({ b: 0 }, { a: null }); // true - 'a' is missing

match({ a: null }, { a: null }); // true - both null

match({ a: undefined }, { a: null }); // true - undefined treated as null

match({ a: false }, { a: null }); // false - false is not null

match({ a: 0 }, { a: null }); // false - 0 is not null

match({ a: '' }, { a: null }); // false - empty string is not null

// Nested non-existing properties
match(
  { a: {} },
  { a: { b: null } }
); // true - 'b' doesn't exist in nested object

match(
  { a: { b: false } },
  { a: { b: null } }
); // false - 'b' exists and is false

// Array with null for optional properties
match(
  { status: 'active' },
  { status: [null, 'active'] }
); // true - matches 'active'

match(
  { name: 'Alice' },
  { status: [null, 'active'] }
); // true - 'status' is missing, matches null

match(
  { status: false },
  { status: [null, 'active'] }
); // false - false doesn't match null or 'active'
```

## Type Coercion

The function coerces values to match the type of the rule:

```javascript
// Boolean coercion
match('hello', true); // true - truthy string
match('', false); // true - falsy empty string
match(0, false); // true - falsy zero
match(1, true); // true - truthy number

// String coercion
match(123, '123'); // true
match(true, 'true'); // true

// Number coercion
match('42', 42); // true
match('3.14', 3.14); // true
```

## Range Matching

Use `min` and `max` for numeric and date range matching:

```javascript
// Numeric ranges
match(5, { min: 1, max: 10 }); // true
match(15, { min: 1, max: 10 }); // false
match(1, { min: 1 }); // true - only minimum
match(10, { max: 10 }); // true - only maximum

// Date ranges
const now = new Date('2025-06-15');
const start = new Date('2025-01-01');
const end = new Date('2025-12-31');

match(now, { min: start, max: end }); // true
match(new Date('2026-01-01'), { min: start, max: end }); // false

// Nested range matching
match(
  { user: { age: 25 } },
  { user: { age: { min: 18, max: 65 } } }
); // true
```

### Grafana-Style Time Intervals

The `min` and `max` properties support Grafana-style relative time intervals
for convenient time-based matching:

```javascript
// Current time
match(new Date(), { min: 'now-1h', max: 'now' });
// true - current time is within the last hour

// Future time check
match(new Date(), { max: 'now+1d' });
// true - current time is before tomorrow

// Past time check
match(new Date(), { min: 'now-1w' });
// true - current time is within the last week

// Time range
match(
  new Date(),
  { min: 'now-1d', max: 'now+1d' }
); // true - current time is within ±1 day

// In nested structures
match(
  { event: { timestamp: new Date() } },
  { event: { timestamp: { min: 'now-5m' } } }
); // true - event occurred within the last 5 minutes

match(
  { event: { timestamp: new Date(Date.now() - 10 * 60 * 1000) } },
  { event: { timestamp: { min: 'now-5m' } } }
); // false - event occurred more than 5 minutes ago
```

#### Supported Time Units

| Unit | Description    | Example     |
| ---- | -------------- | ----------- |
| `ms` | Milliseconds   | `now-500ms` |
| `s`  | Seconds        | `now-30s`   |
| `m`  | Minutes        | `now-5m`    |
| `h`  | Hours          | `now-2h`    |
| `d`  | Days           | `now-7d`    |
| `w`  | Weeks          | `now-2w`    |
| `M`  | Months (30d)   | `now-3M`    |
| `y`  | Years (365d)   | `now-1y`    |

#### Rounding Units

When using the `/` operator, you can round to these time units:

| Unit | Rounds To            | Example                          |
| ---- | -------------------- | -------------------------------- |
| `s`  | Start of second      | `now/s` = current second at .000 |
| `m`  | Start of minute      | `now/m` = current minute at :00  |
| `h`  | Start of hour        | `now/h` = current hour at :00:00 |
| `d`  | Start of day         | `now/d` = today at 00:00:00      |
| `w`  | Start of week        | `now/w` = Monday at 00:00:00     |
| `M`  | Start of month       | `now/M` = 1st of month 00:00:00  |
| `y`  | Start of year        | `now/y` = Jan 1st at 00:00:00    |

**Note**: Week rounding always rounds to Monday as the first day of the week.

#### Time Interval Format

- **`now`**: Current time
- **`now-<amount><unit>`**: Time in the past (e.g., `now-5m` = 5 minutes ago)
- **`now+<amount><unit>`**: Time in the future (e.g., `now+1h` = 1 hour from now)
- **`now/<unit>`**: Current time rounded to start of unit (e.g., `now/d` = start of today)
- **`now[+-]<amount><unit>/<roundUnit>`**: Time with offset, rounded (e.g., `now-5d/d` = 5 days ago at midnight)

#### Time Rounding

The `/` operator rounds timestamps to the start of the specified time unit, following Grafana's approach:

```javascript
// Round to start of current day (midnight)
match(new Date('2025-06-15T14:30:00'), { min: 'now/d', max: 'now' });
// Compares against start of current day

// Round to start of current hour
match(new Date(), { min: 'now/h' });
// Matches times from the start of the current hour

// Combine offset and rounding
// Example: 5 days ago, rounded to midnight of that day
match(new Date(), { min: 'now-5d/d' });

// Week rounding (rounds to Monday)
match(new Date(), { min: 'now/w' });
// Matches times from the start of the current week

// Month rounding
match(new Date(), { min: 'now-1M/M' });
// Matches times from the start of last month

// Rounding units: s (second), m (minute), h (hour), d (day),
// w (week), M (month), y (year)
```

```javascript
// Examples with different units
match(new Date(), { min: 'now-500ms' }); // Last 500 milliseconds
match(new Date(), { min: 'now-30s' }); // Last 30 seconds
match(new Date(), { min: 'now-5m' }); // Last 5 minutes
match(new Date(), { min: 'now-2h' }); // Last 2 hours
match(new Date(), { min: 'now-7d' }); // Last 7 days
match(new Date(), { min: 'now-2w' }); // Last 2 weeks
match(new Date(), { min: 'now-3M' }); // Last 3 months (approx)
match(new Date(), { min: 'now-1y' }); // Last year (approx)

// Future times
match(new Date(), { max: 'now+1h' }); // Within next hour
match(new Date(), { max: 'now+7d' }); // Within next 7 days

// Examples with rounding
match(new Date(), { min: 'now/d' }); // Since midnight today
match(new Date(), { min: 'now-7d/d', max: 'now/d' }); // Last 7 full days
match(new Date(), { min: 'now/M' }); // Since start of current month
match(new Date(), { min: 'now-1y/y', max: 'now/y' }); // Last full year

// Practical examples
// Check if log entry is recent (last 15 minutes)
match(
  { log: { timestamp: new Date() } },
  { log: { timestamp: { min: 'now-15m' } } }
); // true

// Check if event occurred today (since midnight)
match(
  { event: { timestamp: new Date() } },
  { event: { timestamp: { min: 'now/d' } } }
); // true if event is from today

// Check if data is from the current month
match(
  { report: { date: new Date() } },
  { report: { date: { min: 'now/M', max: 'now' } } }
); // true if report is from current month

// Check if scheduled event is upcoming (next 24 hours)
match(
  { event: { scheduledAt: new Date(Date.now() + 12 * 60 * 60 * 1000) } },
  { event: { scheduledAt: { min: 'now', max: 'now+1d' } } }
); // true

// Check if user session is still valid (created within last 30 minutes)
match(
  { session: { createdAt: new Date(Date.now() - 10 * 60 * 1000) } },
  { session: { createdAt: { min: 'now-30m' } } }
); // true

// Daily reports: match events from start of day until now
match(
  { log: { timestamp: new Date() } },
  { log: { timestamp: { min: 'now/d', max: 'now' } } }
); // Matches anything that happened today

// Weekly reports: last 7 complete days
match(
  { metric: { recorded: new Date() } },
  { metric: { recorded: { min: 'now-7d/d', max: 'now/d' } } }
); // Matches data from last 7 full days (midnight to midnight)

// Business hours check: events during current hour
match(
  { transaction: { timestamp: new Date() } },
  { transaction: { timestamp: { min: 'now/h', max: 'now' } } }
); // Matches transactions from start of current hour
```

## Function Predicates

Use functions for custom matching logic:

```javascript
// Function as rule
match(10, (value) => value > 5); // true
match(3, (value) => value > 5); // false

// Complex predicate
match(
  'hello@example.com',
  (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
); // true - valid email

// Function in nested structure
match(
  { user: { age: 25 } },
  { user: { age: (age) => age >= 18 } }
); // true
```

## Negation with `not`

Use the `not` property in an object condition to negate any match:

```javascript
// Basic negation
match(3, { not: 5 }); // true - 3 is not 5
match(5, { not: 5 }); // false - 5 is 5

// Negate string match
match('goodbye', { not: 'hello' }); // true
match('hello', { not: 'hello' }); // false

// Negate boolean
match(false, { not: true }); // true
match(true, { not: true }); // false
match(0, { not: true }); // true - 0 is falsy, not true
match(1, { not: true }); // false - 1 coerces to true

// Negate null
match(0, { not: null }); // true - 0 is not null
match(null, { not: null }); // false
match(undefined, { not: null }); // false - undefined treated as null

// In nested structures
match(
  { status: 'inactive' },
  { status: { not: 'active' } }
); // true

match(
  { status: 'active' },
  { status: { not: 'active' } }
); // false
```

### Negating Complex Conditions

The `not` property works with all match types including arrays, ranges,
functions, and regex:

```javascript
// Negate regex
match('goodbye', { not: /hello/ }); // true
match('hello world', { not: /hello/ }); // false

match(
  { email: 'user@domain.org' },
  { email: { not: /@example\.com$/ } }
); // true - doesn't end with @example.com

// Negate array (none of)
match(5, { not: [1, 2, 3] }); // true - 5 is not in the list
match(2, { not: [1, 2, 3] }); // false - 2 is in the list

match(
  { role: 'guest' },
  { role: { not: ['admin', 'moderator'] } }
); // true - guest is neither admin nor moderator

match(
  { role: 'admin' },
  { role: { not: ['admin', 'moderator'] } }
); // false - admin is in the list

// Negate range
match(3, { not: { min: 5, max: 10 } }); // true - 3 is outside the range
match(7, { not: { min: 5, max: 10 } }); // false - 7 is in the range
match(15, { not: { min: 5, max: 10 } }); // true - 15 is outside the range

match(
  { age: 15 },
  { age: { not: { min: 18, max: 65 } } }
); // true - age is below minimum

match(
  { age: 25 },
  { age: { not: { min: 18, max: 65 } } }
); // false - age is in range

// Negate function predicate
match(3, { not: (v) => v > 5 }); // true - 3 is not > 5
match(10, { not: (v) => v > 5 }); // false - 10 is > 5

match(
  { price: 25 },
  { price: { not: (p) => p >= 100 } }
); // true - price is not >= 100

// Negate object match
match(
  { user: { role: 'guest' } },
  { user: { not: { role: 'admin' } } }
); // true - role is not admin

match(
  { user: { role: 'admin' } },
  { user: { not: { role: 'admin' } } }
); // false - role is admin

// Complex negation in nested structures
match(
  {
    user: {
      email: 'user@custom.com',
      role: 'user'
    }
  },
  {
    user: {
      email: { not: /@example\.com$/ },
      role: { not: ['admin', 'moderator'] }
    }
  }
); // true - email doesn't end with @example.com and role is not admin/moderator
```

### Combining `not` with Other Conditions

You can combine `not` with other conditions in complex matching scenarios:

```javascript
// Exclude certain values while checking other properties
match(
  { status: 'pending', priority: 2 },
  {
    status: { not: ['cancelled', 'completed'] },
    priority: { min: 1, max: 3 }
  }
); // true - status is not cancelled/completed and priority is in range

// Array values with negation
match(
  { tags: ['javascript', 'backend'] },
  { tags: { not: 'frontend' } }
); // true - none of the tags are 'frontend'

match(
  { tags: ['javascript', 'frontend'] },
  { tags: { not: 'frontend' } }
); // false - 'frontend' is in the tags
```

## Regular Expression Matching

```javascript
// Regex patterns
match('hello world', /hello/); // true
match('goodbye world', /hello/); // false

// Case-insensitive matching
match('Hello World', /hello/i); // true

// Nested regex
match(
  { email: 'user@example.com' },
  { email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
); // true
```

## Complex Examples

Combining multiple matching strategies:

```javascript
// Complex nested structure with arrays and nulls
match(
  {
    user: {
      name: 'Alice',
      email: 'alice@example.com',
      roles: ['admin', 'user']
    },
    status: 'active'
  },
  {
    user: {
      email: /@example\.com$/,
      roles: 'admin'
    },
    status: ['active', 'pending'],
    lastLogin: null // optional field
  }
); // true

// Multiple conditions
match(
  { price: 50, category: 'electronics', inStock: true },
  {
    price: { min: 0, max: 100 },
    category: ['electronics', 'computers'],
    inStock: true,
    discount: null // discount is optional
  }
); // true
```

## API

### `match(factValue, ruleValue)`

Compares a fact value against a rule value with flexible matching semantics.

**Parameters:**

- `factValue` (any): The actual value to test
- `ruleValue` (any): The pattern/rule to match against

**Returns:**

- `boolean`: `true` if the fact matches the rule, `false` otherwise

**Matching Rules:**

- **Exact equality**: Returns `true` if values are strictly equal
- **Null handling**: `null` in rule matches `null` or `undefined` in fact
- **Arrays**: "Any of" semantics - at least one element must match
- **Objects**: Recursively matches properties (partial matching allowed)
- **Type coercion**: Values are coerced to match rule type
- **Ranges**: Objects with `min`/`max` properties enable range matching
- **Negation**: Objects with `not` property negate any match condition
- **Functions**: Rule functions are called with fact value as predicate
- **RegExp**: Tests string values against regex patterns

## License

See the main project license.