UNPKG

@sanity/json-match

Version:

A lightweight and lazy implementation of JSONMatch made for JavaScript

924 lines (828 loc) 36.5 kB
import {describe, test, expect, vi} from 'vitest' import {parse} from './parse' import {jsonMatch} from './match' import {getIndexForKey} from './path' vi.mock('./path', async (importOriginal) => { const {getIndexForKey, ...rest} = await importOriginal<typeof import('./path')>() return { ...rest, getIndexForKey: vi.fn(getIndexForKey), } }) describe('Match Function', () => { type User = {name: string; age: number; email?: string; role?: string} const alice: User = {name: 'Alice', age: 25, email: 'alice@example.com', role: 'user'} const bob: User = {name: 'Bob', age: 30, email: 'bob@example.com', role: 'admin'} const carol: User = {name: 'Carol', age: 35, email: 'carol@example.com', role: 'admin'} const jules: User = {name: 'Jules', age: 44, role: 'user'} const testData = { users: [alice, bob, carol, jules], config: { maxUsers: 100, version: '1.0.0', }, bicycle: { color: 'red', type: 'road', }, } const keyedData = { items: [ {_key: 'item1', name: 'First', price: 100}, {_key: 'item2', name: 'Second', price: 200}, {_key: 'item3', name: 'Third', price: 300}, ], } describe('Basic Path Expressions', () => { test('matches simple identifier', () => { const results = Array.from(jsonMatch(testData, parse('users'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe(testData.users) expect(path).toEqual(['users']) }) test('matches dot access', () => { const results = Array.from(jsonMatch(testData, parse('config.version'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe('1.0.0') expect(path).toEqual(['config', 'version']) }) test('matches nested dot access', () => { const results = Array.from(jsonMatch(testData, parse('bicycle.color'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe('red') expect(path).toEqual(['bicycle', 'color']) }) }) describe('Implicit This Access', () => { test('matches implicit this with dot', () => { const results = Array.from(jsonMatch(testData, parse('.bicycle'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe(testData.bicycle) expect(path).toEqual(['bicycle']) }) test('matches implicit this with nested path', () => { const results = Array.from(jsonMatch(testData, parse('.bicycle.color'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe('red') expect(path).toEqual(['bicycle', 'color']) }) test('matches bare recursive descent', () => { const results = Array.from(jsonMatch(testData, parse('..'))) expect(results).toHaveLength(26) expect(results).toEqual([ {path: ['users'], value: testData.users}, {path: ['config'], value: testData.config}, {path: ['bicycle'], value: testData.bicycle}, {path: ['users', 0], value: testData.users[0]}, {path: ['users', 1], value: testData.users[1]}, {path: ['users', 2], value: testData.users[2]}, {path: ['users', 3], value: testData.users[3]}, {path: ['users', 0, 'name'], value: testData.users[0].name}, {path: ['users', 0, 'age'], value: testData.users[0].age}, {path: ['users', 0, 'email'], value: testData.users[0].email}, {path: ['users', 0, 'role'], value: testData.users[0].role}, {path: ['users', 1, 'name'], value: testData.users[1].name}, {path: ['users', 1, 'age'], value: testData.users[1].age}, {path: ['users', 1, 'email'], value: testData.users[1].email}, {path: ['users', 1, 'role'], value: testData.users[1].role}, {path: ['users', 2, 'name'], value: testData.users[2].name}, {path: ['users', 2, 'age'], value: testData.users[2].age}, {path: ['users', 2, 'email'], value: testData.users[2].email}, {path: ['users', 2, 'role'], value: testData.users[2].role}, {path: ['users', 3, 'name'], value: testData.users[3].name}, {path: ['users', 3, 'age'], value: testData.users[3].age}, {path: ['users', 3, 'role'], value: testData.users[3].role}, {path: ['config', 'maxUsers'], value: testData.config.maxUsers}, {path: ['config', 'version'], value: testData.config.version}, {path: ['bicycle', 'color'], value: testData.bicycle.color}, {path: ['bicycle', 'type'], value: testData.bicycle.type}, ]) }) }) describe('Array Access', () => { test('matches array index', () => { const results = Array.from(jsonMatch(testData, parse('users[0]'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(path).toEqual(['users', 0]) expect(value).toBe(alice) }) test('matches negative array index', () => { const results = Array.from(jsonMatch(testData, parse('users[-1]'))) expect(results).toHaveLength(1) const [{value, path}] = results expect(path).toEqual(['users', -1]) expect(value).toEqual(jules) }) test('matches array slice', () => { const results = Array.from(jsonMatch(testData, parse('users[1:3]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.path).toEqual(['users', 1]) expect(first.value).toBe(bob) expect(second.path).toEqual(['users', 2]) expect(second.value).toBe(carol) expect(testData.users.slice(1, 3)).toEqual(results.map((i) => i.value)) }) test('matches array slice with start only', () => { const results = Array.from(jsonMatch(testData, parse('users[1:]'))) expect(results).toHaveLength(3) const [first, second, third] = results expect(first.path).toEqual(['users', 1]) expect(second.path).toEqual(['users', 2]) expect(third.path).toEqual(['users', 3]) expect(testData.users.slice(1, undefined)).toEqual(results.map((i) => i.value)) }) test('matches array slice with end only', () => { const results = Array.from(jsonMatch(testData, parse('users[:2]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.path).toEqual(['users', 0]) expect(second.path).toEqual(['users', 1]) expect(testData.users.slice(undefined, 2)).toEqual(results.map((i) => i.value)) }) test('matches negative slice with both start and end', () => { const results = Array.from(jsonMatch(testData, parse('users[-3:-1]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.path).toEqual(['users', 1]) expect(first.value).toEqual(testData.users[1]) expect(second.path).toEqual(['users', 2]) expect(second.value).toEqual(testData.users[2]) expect(testData.users.slice(-3, -1)).toEqual(results.map((i) => i.value)) }) test('matches negative slice with start only', () => { const results = Array.from(jsonMatch(testData, parse('users[-2:]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.path).toEqual(['users', 2]) expect(first.value).toEqual(testData.users[2]) expect(second.path).toEqual(['users', 3]) expect(second.value).toEqual(testData.users[3]) expect(testData.users.slice(-2, undefined)).toEqual(results.map((i) => i.value)) }) test('matches negative slice with end only', () => { const results = Array.from(jsonMatch(testData, parse('users[:-2]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.path).toEqual(['users', 0]) expect(first.value).toEqual(testData.users[0]) expect(second.path).toEqual(['users', 1]) expect(second.value).toEqual(testData.users[1]) expect(testData.users.slice(undefined, -2)).toEqual(results.map((i) => i.value)) }) test('matches mixed positive and negative slice indices', () => { const results = Array.from(jsonMatch(testData, parse('users[1:-1]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.path).toEqual(['users', 1]) expect(first.value).toEqual(testData.users[1]) expect(second.path).toEqual(['users', 2]) expect(second.value).toEqual(testData.users[2]) expect(testData.users.slice(1, -1)).toEqual(results.map((i) => i.value)) }) test('matches negative slice from end to positive index', () => { const results = Array.from(jsonMatch(testData, parse('users[-3:2]'))) expect(results).toHaveLength(1) const [first] = results expect(first.path).toEqual(['users', 1]) expect(first.value).toEqual(testData.users[1]) expect(testData.users.slice(-3, 2)).toEqual(results.map((i) => i.value)) }) test('matches negative slice that exceeds array bounds', () => { const results = Array.from(jsonMatch(testData, parse('users[-10:-8]'))) expect(results).toHaveLength(0) // Should return empty for out-of-bounds indices expect(testData.users.slice(-10, -8)).toEqual(results.map((i) => i.value)) }) test('matches negative slice with equal start and end indices', () => { const results = Array.from(jsonMatch(testData, parse('users[-2:-2]'))) expect(results).toHaveLength(0) // Should return empty for equal indices expect(testData.users.slice(-2, -2)).toEqual(results.map((i) => i.value)) }) test('matches negative slice where start is greater than end', () => { const results = Array.from(jsonMatch(testData, parse('users[-1:-3]'))) expect(results).toHaveLength(0) // Should return empty when start > end expect(testData.users.slice(-1, -3)).toEqual(results.map((i) => i.value)) }) }) describe('Wildcards', () => { test('matches wildcard on object', () => { const results = Array.from(jsonMatch(testData, parse('bicycle.*'))) expect(results).toHaveLength(2) const first = results[0] const second = results[1] expect(first.path).toEqual(['bicycle', 'color']) expect(second.path).toEqual(['bicycle', 'type']) expect(first.value).toEqual('red') expect(second.value).toEqual('road') }) test('matches wildcard on array', () => { const results = Array.from(jsonMatch(testData, parse('users[*]'))) expect(results).toHaveLength(4) const [first, second, third, fourth] = results expect(first.path).toEqual(['users', 0]) expect(second.path).toEqual(['users', 1]) expect(third.path).toEqual(['users', 2]) expect(fourth.path).toEqual(['users', 3]) }) test('matches wildcard with property access', () => { const results = Array.from(jsonMatch(testData, parse('users[*].name'))) expect(results).toHaveLength(4) const [first, second, third, fourth] = results expect(first.value).toEqual('Alice') expect(first.path).toEqual(['users', 0, 'name']) expect(second.value).toEqual('Bob') expect(second.path).toEqual(['users', 1, 'name']) expect(third.value).toEqual('Carol') expect(third.path).toEqual(['users', 2, 'name']) expect(fourth.value).toEqual('Jules') expect(fourth.path).toEqual(['users', 3, 'name']) }) }) describe('Boolean Literals', () => { test('matches items with boolean comparison (true)', () => { const data = { items: [ {id: 1, active: true}, {id: 2, active: false}, {id: 3, active: true}, ], } const results = Array.from(jsonMatch(data, parse('items[active == true]'))) expect(results).toHaveLength(2) expect(results[0].value).toBe(data.items[0]) expect(results[0].path).toEqual(['items', 0]) expect(results[1].value).toBe(data.items[2]) expect(results[1].path).toEqual(['items', 2]) }) test('matches items with boolean comparison (false)', () => { const data = { items: [ {id: 1, active: true}, {id: 2, active: false}, {id: 3, active: true}, ], } const results = Array.from(jsonMatch(data, parse('items[active == false]'))) expect(results).toHaveLength(1) expect(results[0].value).toBe(data.items[1]) expect(results[0].path).toEqual(['items', 1]) }) test('matches items with boolean inequality', () => { const data = { items: [ {id: 1, visible: true}, {id: 2, visible: false}, {id: 3, visible: true}, ], } const results = Array.from(jsonMatch(data, parse('items[visible != false]'))) expect(results).toHaveLength(2) expect(results[0].value).toEqual({id: 1, visible: true}) expect(results[1].value).toEqual({id: 3, visible: true}) }) test('handles boolean literals in arrays', () => { const data = {values: [true, false, true, false]} const results = Array.from(jsonMatch(data, parse('values[@ == true]'))) expect(results).toHaveLength(2) expect(results[0].value).toBe(true) expect(results[0].path).toEqual(['values', 0]) expect(results[1].value).toBe(true) expect(results[1].path).toEqual(['values', 2]) }) }) describe('Constraints', () => { test('matches comparison constraint', () => { const results = Array.from(jsonMatch(testData, parse('users[age > 28]'))) expect(results).toHaveLength(3) const [first, second, third] = results expect(first.value).toEqual({ name: 'Bob', age: 30, email: 'bob@example.com', role: 'admin', }) expect(first.path).toEqual(['users', 1]) expect(second.value).toEqual({ name: 'Carol', age: 35, email: 'carol@example.com', role: 'admin', }) expect(second.path).toEqual(['users', 2]) expect(third.value).toEqual({ name: 'Jules', age: 44, role: 'user', }) expect(third.path).toEqual(['users', 3]) }) test('matches equality constraint', () => { const results = Array.from(jsonMatch(testData, parse('users[name == "Alice"]'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toEqual({ name: 'Alice', age: 25, email: 'alice@example.com', role: 'user', }) expect(path).toEqual(['users', 0]) }) test('matches constraint with property access', () => { const results = Array.from(jsonMatch(testData, parse('users[age > 28].name'))) expect(results).toHaveLength(3) const [first, second, third] = results expect(first.value).toEqual('Bob') expect(first.path).toEqual(['users', 1, 'name']) expect(second.value).toEqual('Carol') expect(second.path).toEqual(['users', 2, 'name']) expect(third.value).toEqual('Jules') expect(third.path).toEqual(['users', 3, 'name']) }) test('matches existence constraint', () => { const results = Array.from(jsonMatch(testData, parse('users[email?]'))) expect(results).toHaveLength(3) // Alice, Bob, and Carol have email (Jules doesn't have email) const [first, second, third] = results expect(first.path).toEqual(['users', 0]) expect(second.path).toEqual(['users', 1]) expect(third.path).toEqual(['users', 2]) }) test('matches multiple constraints (OR logic)', () => { const results = Array.from( jsonMatch(testData, parse('users[age > 32, name == "Alice"].name')), ) expect(results).toHaveLength(3) // Carol (age > 32), Jules (age > 32), and Alice (name == "Alice") const [first, second, third] = results expect(first.value).toEqual('Carol') expect(first.path).toEqual(['users', 2, 'name']) expect(second.value).toEqual('Jules') expect(second.path).toEqual(['users', 3, 'name']) expect(third.value).toEqual('Alice') expect(third.path).toEqual(['users', 0, 'name']) }) test('matches multiple chained constraints (AND logic)', () => { const results = Array.from( jsonMatch(testData, parse('users[role == "admin"][age > 32].name')), ) expect(results).toHaveLength(1) // Carol (age > 32) (role == "admin") const [first] = results expect(first.value).toEqual('Carol') expect(first.path).toEqual(['users', 2, 'name']) }) test('matches inequality constraint (!=)', () => { const results = Array.from(jsonMatch(testData, parse('users[role != "admin"]'))) expect(results).toHaveLength(2) // Alice and Jules are not admin const [first, second] = results expect(first.path).toEqual(['users', 0]) expect(first.value).toBe(alice) expect(second.path).toEqual(['users', 3]) expect(second.value).toBe(jules) }) test('matches less than or equal constraint (<=)', () => { const results = Array.from(jsonMatch(testData, parse('users[age <= 30]'))) expect(results).toHaveLength(2) // Alice (25) and Bob (30) const [first, second] = results expect(first.path).toEqual(['users', 0]) expect(first.value).toBe(alice) expect(second.path).toEqual(['users', 1]) expect(second.value).toBe(bob) }) test('matches greater than or equal constraint (>=)', () => { const results = Array.from(jsonMatch(testData, parse('users[age >= 35]'))) expect(results).toHaveLength(2) // Carol (35) and Jules (44) const [first, second] = results expect(first.path).toEqual(['users', 2]) expect(first.value).toBe(carol) expect(second.path).toEqual(['users', 3]) expect(second.value).toBe(jules) }) test('matches less than constraint (<)', () => { const results = Array.from(jsonMatch(testData, parse('users[age < 30]'))) expect(results).toHaveLength(1) // Only Alice (25) const [first] = results expect(first.path).toEqual(['users', 0]) expect(first.value).toBe(alice) }) test('handles comparison with non-numeric values', () => { const results = Array.from(jsonMatch(testData, parse('users[name > "Bob"]'))) expect(results).toHaveLength(0) // String comparisons should not work for >, <, <=, >= }) test('handles comparison where left expression evaluates to nothing', () => { const results = Array.from(jsonMatch(testData, parse('users[nonexistent == "test"]'))) expect(results).toHaveLength(0) // Should return empty when left side doesn't exist }) test('handles comparison where right expression evaluates to nothing', () => { const results = Array.from(jsonMatch(testData, parse('users[name == nonexistent]'))) expect(results).toHaveLength(0) // Should return empty when right side doesn't exist }) test('handles object-level comparison (not on array)', () => { const objectData = { user: {name: 'Alice', age: 25}, settings: {theme: 'dark'}, } const results = Array.from(jsonMatch(objectData, parse('user[age == 25]'))) expect(results).toHaveLength(1) const [first] = results expect(first.value).toEqual({name: 'Alice', age: 25}) expect(first.path).toEqual(['user']) }) test('handles object-level comparison with inequality', () => { const objectData = { user: {name: 'Alice', age: 25}, } const results = Array.from(jsonMatch(objectData, parse('user[age != 30]'))) expect(results).toHaveLength(1) const [first] = results expect(first.value).toEqual({name: 'Alice', age: 25}) expect(first.path).toEqual(['user']) }) }) describe('Keyed Objects', () => { test('uses _key for keyed array items', () => { const results = Array.from(jsonMatch(keyedData, parse('items[0]'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toEqual({ _key: 'item1', name: 'First', price: 100, }) expect(path).toEqual(['items', {_key: 'item1'}]) }) test('uses _key for keyed items in wildcard', () => { const results = Array.from(jsonMatch(keyedData, parse('items[*]'))) expect(results).toHaveLength(3) const [first, second, third] = results expect(first.path).toEqual(['items', {_key: 'item1'}]) expect(second.path).toEqual(['items', {_key: 'item2'}]) expect(third.path).toEqual(['items', {_key: 'item3'}]) }) test('uses _key for keyed items in constraint', () => { const results = Array.from(jsonMatch(keyedData, parse('items[price > 150]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.path).toEqual(['items', {_key: 'item2'}]) expect(second.path).toEqual(['items', {_key: 'item3'}]) }) test('has special speed up for keyed constraints', () => { vi.mocked(getIndexForKey).mockClear() expect(getIndexForKey).not.toHaveBeenCalled() const results = Array.from(jsonMatch(keyedData, parse('items[_key == "item2"]'))) expect(results).toHaveLength(1) const [first] = results expect(first.path).toEqual(['items', {_key: 'item2'}]) expect(getIndexForKey).toHaveBeenCalledTimes(1) }) test('has special speed up for keyed constraints (string literal first)', () => { vi.mocked(getIndexForKey).mockClear() expect(getIndexForKey).not.toHaveBeenCalled() const results = Array.from(jsonMatch(keyedData, parse('items["item2" == _key]'))) expect(results).toHaveLength(1) const [first] = results expect(first.path).toEqual(['items', {_key: 'item2'}]) expect(getIndexForKey).toHaveBeenCalledTimes(1) }) }) describe('Expression Unions', () => { test('matches simple union', () => { const results = Array.from(jsonMatch(testData, parse('[users, config]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.path).toEqual(['users']) expect(first.value).toBe(testData.users) expect(second.path).toEqual(['config']) expect(second.value).toBe(testData.config) }) test('matches union with property access', () => { const results = Array.from(jsonMatch(testData, parse('[bicycle.color, config.version]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.value).toEqual('red') expect(first.path).toEqual(['bicycle', 'color']) expect(second.value).toEqual('1.0.0') expect(second.path).toEqual(['config', 'version']) }) }) describe('Complex Expressions', () => { test('matches complex nested expression', () => { const complexData = { data: { items: [ {tags: ['red', 'blue'], price: 100}, {tags: ['green'], price: 200}, ], }, } const results = Array.from(jsonMatch(complexData, parse('data.items[*].tags[0]'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.value).toEqual('red') expect(first.path).toEqual(['data', 'items', 0, 'tags', 0]) expect(second.value).toEqual('green') expect(second.path).toEqual(['data', 'items', 1, 'tags', 0]) }) test('matches path with multiple subscripts', () => { const matrixData = { matrix: [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ], } const results = Array.from(jsonMatch(matrixData, parse('matrix[1][2]'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe(6) expect(path).toEqual(['matrix', 1, 2]) }) }) describe('Edge Cases', () => { test('yields `undefined` value if the value is an object and the path is non-existent', () => { const results = Array.from(jsonMatch(testData, parse('nonexistent'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe(undefined) expect(path).toEqual(['nonexistent']) }) test('yields `undefined` value for non-existent array access', () => { const results = Array.from(jsonMatch(testData, parse('users[999]'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe(undefined) expect(path).toEqual(['users', 999]) }) test('yields `undefined` for key constraints on existing arrays', () => { const results = Array.from(jsonMatch(keyedData, parse('items[_key == "item4"]'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe(undefined) expect(path).toEqual(['items', {_key: 'item4'}]) }) test('returns empty for constraints on non-arrays', () => { const results = Array.from(jsonMatch(testData, parse('config[name == "test"]'))) expect(results).toHaveLength(0) }) test('handles empty data', () => { const results = Array.from(jsonMatch({}, parse('anything'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe(undefined) expect(path).toEqual(['anything']) }) test('handles null values', () => { const results = Array.from(jsonMatch({value: null}, parse('value'))) expect(results).toHaveLength(1) const {value} = results[0] expect(value).toBe(null) }) test('handles string literals', () => { const results = Array.from(jsonMatch(testData, parse('["some", "literals", ["here"]]'))) expect(results).toHaveLength(0) }) test('handles boolean literals', () => { const results = Array.from(jsonMatch(testData, parse('[true, false]'))) expect(results).toHaveLength(0) }) test('dedupes matches from multiple candidates', () => { const results = Array.from(jsonMatch(testData, 'users[email?, age > 5]')) expect(results).toHaveLength(4) expect(results).toEqual([ {path: ['users', 0], value: testData.users[0]}, {path: ['users', 1], value: testData.users[1]}, {path: ['users', 2], value: testData.users[2]}, {path: ['users', 3], value: testData.users[3]}, ]) }) test('returns nested undefined matches when trying to match identifier on array', () => { const results = Array.from(jsonMatch(testData, parse('users.someProperty'))) expect(results).toHaveLength(4) expect(results).toEqual([ {path: ['users', 0, 'someProperty'], value: undefined}, {path: ['users', 1, 'someProperty'], value: undefined}, {path: ['users', 2, 'someProperty'], value: undefined}, {path: ['users', 3, 'someProperty'], value: undefined}, ]) }) test('returns empty when trying to slice on non-array', () => { const results = Array.from(jsonMatch(testData, parse('config[1:3]'))) expect(results).toHaveLength(0) // Cannot slice an object }) test('yields undefined when trying to index on non-array/object', () => { const results = Array.from(jsonMatch(testData, parse('config.version[0]'))) expect(results).toHaveLength(1) const {value, path} = results[0] expect(value).toBe(undefined) expect(path).toEqual(['config', 'version', 0]) }) test('handles null literal as a standalone expression', () => { const results = Array.from(jsonMatch({value: null}, parse('null'))) // Should not match any path, only yield the literal value with LITERAL_PATH (which is skipped) expect(results).toHaveLength(0) }) test('handles null in union expression', () => { const results = Array.from(jsonMatch({value: null}, parse('[null, true, false]'))) // Should not match any path, only yield the literal values with LITERAL_PATH (which is skipped) expect(results).toHaveLength(0) }) test('handles null in comparison constraint', () => { const data = { items: [{value: null}, {value: 1}, {value: null}], } const results = Array.from(jsonMatch(data, parse('items[value == null]'))) expect(results).toHaveLength(2) expect(results[0].value).toBe(data.items[0]) expect(results[0].path).toEqual(['items', 0]) expect(results[1].value).toBe(data.items[2]) expect(results[1].path).toEqual(['items', 2]) }) test('handles null as a property value in comparison', () => { const data = { items: [{property: null}, {property: 'not-null'}, {property: null}], } const results = Array.from(jsonMatch(data, parse('items[property == null]'))) expect(results).toHaveLength(2) expect(results[0].value).toBe(data.items[0]) expect(results[0].path).toEqual(['items', 0]) expect(results[1].value).toBe(data.items[2]) expect(results[1].path).toEqual(['items', 2]) }) }) describe('Identifier on Array', () => { test('evaluates identifier against array items recursively', () => { const arrayData = { items: [ {name: 'Alice', age: 25}, {name: 'Bob', age: 30}, {name: 'Carol', age: 35}, ], } // When evaluating 'name' against an array, it should apply to each array item const results = Array.from(jsonMatch(arrayData, parse('items.name'))) expect(results).toHaveLength(3) const [first, second, third] = results expect(first.value).toBe('Alice') expect(first.path).toEqual(['items', 0, 'name']) expect(second.value).toBe('Bob') expect(second.path).toEqual(['items', 1, 'name']) expect(third.value).toBe('Carol') expect(third.path).toEqual(['items', 2, 'name']) }) test('evaluates identifier against array items with nested properties', () => { const nestedData = { users: [ {profile: {firstName: 'Alice', lastName: 'Smith'}}, {profile: {firstName: 'Bob', lastName: 'Jones'}}, ], } const results = Array.from(jsonMatch(nestedData, parse('users.profile.firstName'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.value).toBe('Alice') expect(first.path).toEqual(['users', 0, 'profile', 'firstName']) expect(second.value).toBe('Bob') expect(second.path).toEqual(['users', 1, 'profile', 'firstName']) }) test('evaluates identifier against array items with mixed data types', () => { const mixedData = { items: [ {name: 'Alice', active: true}, {name: 'Bob', active: false}, {name: 'Carol', active: true}, ], } const results = Array.from(jsonMatch(mixedData, parse('items.active'))) expect(results).toHaveLength(3) const [first, second, third] = results expect(first.value).toBe(true) expect(first.path).toEqual(['items', 0, 'active']) expect(second.value).toBe(false) expect(second.path).toEqual(['items', 1, 'active']) expect(third.value).toBe(true) expect(third.path).toEqual(['items', 2, 'active']) }) test('evaluates identifier against array items with optional properties', () => { const optionalData = { users: [ {name: 'Alice', email: 'alice@example.com'}, {name: 'Bob'}, // no email {name: 'Carol', email: 'carol@example.com'}, ], } const results = Array.from(jsonMatch(optionalData, parse('users.email'))) // Only Alice and Carol have email but Bob will match with `undefined` expect(results).toHaveLength(3) const [first, second, third] = results expect(first.value).toBe('alice@example.com') expect(first.path).toEqual(['users', 0, 'email']) expect(second.value).toBe(undefined) expect(second.path).toEqual(['users', 1, 'email']) expect(third.value).toBe('carol@example.com') expect(third.path).toEqual(['users', 2, 'email']) }) test('evaluates identifier against array items with keyed objects', () => { const keyedArrayData = { items: [ {_key: 'item1', name: 'First', price: 100}, {_key: 'item2', name: 'Second', price: 200}, ], } const results = Array.from(jsonMatch(keyedArrayData, parse('items.name'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.value).toBe('First') expect(first.path).toEqual(['items', {_key: 'item1'}, 'name']) expect(second.value).toBe('Second') expect(second.path).toEqual(['items', {_key: 'item2'}, 'name']) }) test('evaluates identifier against empty array', () => { const emptyArrayData = { items: [], } const results = Array.from(jsonMatch(emptyArrayData, parse('items.name'))) expect(results).toHaveLength(0) }) test('evaluates identifier against array with mixed primitive and object items', () => { const mixedArrayData = { items: ['string1', {name: 'Alice', age: 25}, 'string2', {name: 'Bob', age: 30}], } // Should match all items: undefined for strings, actual values for objects const results = Array.from(jsonMatch(mixedArrayData, parse('items.name'))) expect(results).toHaveLength(4) const [first, second, third, fourth] = results expect(first.value).toBe(undefined) expect(first.path).toEqual(['items', 0, 'name']) expect(second.value).toBe('Alice') expect(second.path).toEqual(['items', 1, 'name']) expect(third.value).toBe(undefined) expect(third.path).toEqual(['items', 2, 'name']) expect(fourth.value).toBe('Bob') expect(fourth.path).toEqual(['items', 3, 'name']) }) test('evaluates identifier against array with null and undefined items', () => { const nullArrayData = { items: [{name: 'Alice'}, null, {name: 'Bob'}, undefined, {name: 'Carol'}], } const results = Array.from(jsonMatch(nullArrayData, parse('items.name'))) expect(results).toHaveLength(5) // All items: objects with values and null/undefined with undefined const [first, second, third, fourth, fifth] = results expect(first.value).toBe('Alice') expect(first.path).toEqual(['items', 0, 'name']) expect(second.value).toBe(undefined) expect(second.path).toEqual(['items', 1, 'name']) expect(third.value).toBe('Bob') expect(third.path).toEqual(['items', 2, 'name']) expect(fourth.value).toBe(undefined) expect(fourth.path).toEqual(['items', 3, 'name']) expect(fifth.value).toBe('Carol') expect(fifth.path).toEqual(['items', 4, 'name']) }) test('evaluates identifier against array with deeply nested objects', () => { const deepData = { data: [ {user: {profile: {personal: {name: 'Alice', age: 25}}}}, {user: {profile: {personal: {name: 'Bob', age: 30}}}}, ], } const results = Array.from(jsonMatch(deepData, parse('data.user.profile.personal.name'))) expect(results).toHaveLength(2) const [first, second] = results expect(first.value).toBe('Alice') expect(first.path).toEqual(['data', 0, 'user', 'profile', 'personal', 'name']) expect(second.value).toBe('Bob') expect(second.path).toEqual(['data', 1, 'user', 'profile', 'personal', 'name']) }) }) describe('Generator Behavior', () => { test('can get first match only', () => { const query = parse('users[*].name') const generator = jsonMatch(testData, query) const first = generator.next() expect(first.done).toBe(false) const {value, path} = first.value expect(value).toBe('Alice') expect(path).toEqual(['users', 0, 'name']) }) test('allows early termination', () => { const query = parse('users[*].name') const generator = jsonMatch(testData, query) // Get first two results const results = [] for (const result of generator) { results.push(result) if (results.length === 2) break } expect(results).toHaveLength(2) const [first, second] = results expect(first.value).toEqual('Alice') expect(first.path).toEqual(['users', 0, 'name']) expect(second.value).toEqual('Bob') expect(second.path).toEqual(['users', 1, 'name']) }) }) })