npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lokalise/odata-mapper

v1.2.0

Published

Transform low-level OData AST from [@balena/odata-parser](https://github.com/balena-io-modules/odata-parser) into high-level, service-ready structures.

Downloads

530

Readme

@lokalise/odata-mapper

Transform low-level OData AST from @balena/odata-parser into high-level, service-ready structures.

Installation

npm install @lokalise/odata-mapper @balena/odata-parser

Quick Start

import { parseAndTransformFilter, extractEqualityValue, extractInValues } from '@lokalise/odata-mapper'

// Parse and transform in one step
const filter = parseAndTransformFilter("status eq 'active' and parentId in ('root', 'parent-123')")

const status = extractEqualityValue<string>(filter, 'status')     // 'active'
const parentIds = extractInValues<string>(filter, 'parentId')     // ['root', 'parent-123']

For more control (e.g. handling optional filters), use the two-step approach:

import { parseODataFilter, transformFilter, extractEqualityValue } from '@lokalise/odata-mapper'

const parsed = parseODataFilter(queryString) // returns { tree: null, ... } for empty input

if (parsed.tree) {
  const filter = transformFilter(parsed.tree, parsed.binds)
  const status = extractEqualityValue<string>(filter, 'status')
}

Real-World Usage

Dynamic Filter Handling

For services where you don't know which fields users will filter on:

import {
  parseODataFilter,
  extractAllFieldValues,
  getFilteredFieldNames,
  createFilterMap,
  transformFilter,
} from '@lokalise/odata-mapper'

// User sends: $filter=status eq 'active' and categoryId in (1, 2, 3) and contains(name, 'test')
const parsed = parseODataFilter(queryString)

if (parsed.tree) {
  // Option 1: Get all field values at once as a Map
  const fieldValues = extractAllFieldValues(parsed.tree, parsed.binds)
  // Map {
  //   'status' => ['active'],
  //   'categoryId' => [1, 2, 3],
  //   'name' => ['test']
  // }

  // Use in your database query builder
  for (const [field, values] of fieldValues) {
    if (allowedFields.includes(field)) {
      queryBuilder.where(field, values.length === 1 ? values[0] : values)
    }
  }

  // Option 2: Check which fields are being filtered
  const filter = transformFilter(parsed.tree, parsed.binds)
  const filteredFields = getFilteredFieldNames(filter)
  // ['status', 'categoryId', 'name']

  // Option 3: Get full filter details with createFilterMap
  const filterMap = createFilterMap(filter)
  for (const [field, filters] of filterMap) {
    for (const f of filters) {
      if (f.type === 'comparison') {
        // Handle equality, gt, lt, etc.
      } else if (f.type === 'in') {
        // Handle IN filters
      } else if (f.type === 'string-function') {
        // Handle contains, startswith, etc.
      }
    }
  }
}

Known Field Extraction

When you know the specific fields your service supports:

import {
  parseAndTransformFilter,
  extractEqualityValue,
  extractInValues,
  extractRange,
  extractStringFunction,
  findUnsupportedField,
} from '@lokalise/odata-mapper'

const filter = parseAndTransformFilter(queryString)

// Validate that only allowed fields are used
const SUPPORTED = new Set(['status', 'categoryId', 'price', 'name'])
const unsupported = findUnsupportedField(filter, SUPPORTED)
if (unsupported) {
  throw new Error(`Unsupported filter field: ${unsupported}`)
}

// Extract only the fields you support (undefined if not present)
const filters = {
  status: extractEqualityValue<string>(filter, 'status'),
  categoryIds: extractInValues<number>(filter, 'categoryId'),
  priceRange: extractRange(filter, 'price'),
  nameSearch: extractStringFunction(filter, 'name', 'contains')?.value,
}

// Build your query conditionally
const query = db.select().from('products')
if (filters.status) query.where('status', filters.status)
if (filters.categoryIds) query.whereIn('categoryId', filters.categoryIds)
if (filters.priceRange?.min) query.where('price', '>=', filters.priceRange.min)
if (filters.priceRange?.max) query.where('price', '<=', filters.priceRange.max)
if (filters.nameSearch) query.whereLike('name', `%${filters.nameSearch}%`)

Parent Filter Use Case

import { parseAndTransformFilter, extractInValues } from '@lokalise/odata-mapper'

// Parse: $filter=parentId in ('root', 'parent-123', 'parent-456')
const filter = parseAndTransformFilter(queryString)
const parentIds = extractInValues<string>(filter, 'parentId')
// ['root', 'parent-123', 'parent-456']

// Use directly in your service
const files = await fileService.getFilesForParents(parentIds)

Error Handling

parseAndTransformFilter throws FilterNotSupportedError (HTTP 400) for invalid filters — no manual error mapping needed:

import { parseAndTransformFilter, isFilterNotSupportedError } from '@lokalise/odata-mapper'

try {
  const filter = parseAndTransformFilter(userInput)
  // ... use filter
} catch (error) {
  if (isFilterNotSupportedError(error)) {
    // Already an HTTP 400 error with FILTER_NOT_SUPPORTED code
    console.error(`Invalid filter: ${error.message}`)
    console.error(`Details: ${JSON.stringify(error.details)}`)
  }
  throw error
}

API Reference

Parsing

parseODataFilter(filter)

Convenience wrapper for parsing OData $filter expressions. Handles null/empty strings gracefully and provides structured error handling.

const parsed = parseODataFilter("status eq 'active'")
// { tree: FilterTreeNode | null, binds: ODataBinds, originalFilter: string | undefined }

// Handle empty/null filters
const empty = parseODataFilter(undefined)
// { tree: null, binds: [], originalFilter: undefined }

parseAndTransformFilter(filter)

Convenience function that combines parseODataFilter + transformFilter in one step. Throws FilterNotSupportedError (HTTP 400) for empty, whitespace-only, or syntactically invalid filters. Unknown errors are re-thrown as-is.

import { parseAndTransformFilter, extractEqualityValue } from '@lokalise/odata-mapper'

const filter = parseAndTransformFilter("status eq 'active'")
const status = extractEqualityValue<string>(filter, 'status') // 'active'

FilterNotSupportedError

Error thrown when a filter expression is invalid or unsupported. Extends PublicNonRecoverableError from @lokalise/node-core with HTTP 400 status and FILTER_NOT_SUPPORTED error code.

import { FilterNotSupportedError } from '@lokalise/odata-mapper'

// Thrown automatically by parseAndTransformFilter
// Can also be used directly for domain-specific validation:
throw new FilterNotSupportedError({
  message: 'Only driveId filters are supported',
  details: { filter },
})

ODataParseError

Low-level error thrown by parseODataFilter. Most consumers should use parseAndTransformFilter instead, which converts this to FilterNotSupportedError automatically.

class ODataParseError extends Error {
  filter: string      // The original filter string that failed to parse
  cause?: Error       // The underlying parser error
}

Core Transformation

transformFilter(tree, binds, options?)

Transforms a filter AST into a high-level TransformedFilter structure.

const filter = transformFilter(parsed.tree, parsed.binds)
// Returns: TransformedFilter (ComparisonFilter | InFilter | LogicalFilter | ...)

Value Extraction

extractEqualityValue<T>(filter, fieldName)

Extracts a single value from an equality comparison (eq operator).

const status = extractEqualityValue<string>(filter, 'status')
const isActive = extractEqualityValue<boolean>(filter, 'isActive')
const deletedAt = extractEqualityValue(filter, 'deletedAt') // null if eq null

extractInValues<T>(filter, fieldName)

Extracts an array of values from an in filter.

const parentIds = extractInValues<string>(filter, 'parentId')
// ['root', 'parent-123', 'parent-456']

extractFieldValues<T>(filter, fieldName)

Universal extraction that works for both equality and in filters. Always returns an array.

// Works for eq: status eq 'active' -> ['active']
// Works for in: status in ('a', 'b') -> ['a', 'b']
const values = extractFieldValues<string>(filter, 'status')

extractComparison(filter, fieldName, operator)

Extracts a comparison filter for any operator.

const priceGt = extractComparison(filter, 'price', 'gt')
// { type: 'comparison', field: 'price', operator: 'gt', value: 100 }

extractRange(filter, fieldName)

Extracts range filters from combined gt/ge and lt/le operators.

// From: price ge 100 and price le 500
const range = extractRange(filter, 'price')
// { min: 100, minInclusive: true, max: 500, maxInclusive: true }

extractInclusiveRange(filter, fieldName)

Like extractRange, but enforces that only inclusive operators (ge/le) are used. Returns a simplified { min?, max? } without inclusivity flags. Throws if gt or lt operators are found.

// From: price ge 100 and price le 500
const range = extractInclusiveRange(filter, 'price')
// { min: 100, max: 500 }

// From: price gt 100 — throws Error

extractStringFunction(filter, fieldName, functionName?)

Extracts string function filters (contains, startswith, endswith, substringof).

const search = extractStringFunction(filter, 'name', 'contains')
// { type: 'string-function', function: 'contains', field: 'name', value: 'John' }

Filter Inspection

hasFieldFilter(filter, fieldName)

Checks if a field is filtered anywhere in the filter tree.

if (hasFieldFilter(filter, 'category')) {
  // category is being filtered
}

getFilteredFieldNames(filter)

Returns all field names that are filtered.

const fields = getFilteredFieldNames(filter)
// ['status', 'price', 'name']

getFiltersForField(filter, fieldName)

Returns all filters for a specific field (useful when a field appears multiple times).

const priceFilters = getFiltersForField(filter, 'price')
// Could return multiple filters: [{ operator: 'ge', ... }, { operator: 'le', ... }]

findUnsupportedField(filter, supportedFields)

Returns the first field name that is not in the supported set, or undefined if all fields are supported. Accepts a Set<string> or string[].

import { parseAndTransformFilter, findUnsupportedField } from '@lokalise/odata-mapper'

const filter = parseAndTransformFilter("status eq 'active' and priority gt 5")

const unsupported = findUnsupportedField(filter, new Set(['status']))
// 'priority'

const allGood = findUnsupportedField(filter, new Set(['status', 'priority']))
// undefined

Bulk Extraction

extractAllFieldValues(tree, binds)

Extracts all field values from a filter in one pass. Returns a Map<string, FilterValue[]>.

const fieldValues = extractAllFieldValues(parsed.tree, parsed.binds)
// Map {
//   'status' => ['active'],
//   'categoryId' => [1, 2, 3]
// }

Filter Collection

flattenFilters(filter)

Flattens nested logical filters into a single array.

const allFilters = flattenFilters(filter)
// Array of all leaf filters (comparisons, ins, string functions)

collectAndFilters(filter) / collectOrFilters(filter)

Collects filters from AND/OR logical groups.

const andFilters = collectAndFilters(filter)  // Filters that must all match
const orFilters = collectOrFilters(filter)    // Filters where any can match

Supported Filter Types

Comparison Operators

  • eq - Equal
  • ne - Not equal
  • gt - Greater than
  • ge - Greater than or equal
  • lt - Less than
  • le - Less than or equal

Logical Operators

  • and - All conditions must match
  • or - Any condition can match
  • not - Negates a condition

String Functions

  • contains(field, 'value')
  • startswith(field, 'prefix')
  • endswith(field, 'suffix')
  • substringof('value', field)
  • tolower(field)
  • toupper(field)

Collection Operator

  • in - Value is in a list: field in ('a', 'b', 'c')

Nested Properties

Supports nested property access: address/city eq 'NYC'

Low-Level Utilities

These utilities are exported for advanced use cases such as building custom transformers or working directly with the balena parser AST.

Bind Resolution

import {
  resolveBind,           // Resolve a single bind reference to its value
  resolveBinds,          // Resolve multiple bind references
  isBindReference,       // Type guard for bind references
  getBindKey,            // Get the key from a bind reference
  extractBindTupleValue, // Extract value from a [type, value] bind tuple
  extractBindTupleValues,// Extract values from an array of bind tuples
} from '@lokalise/odata-mapper'

AST Utilities

import {
  isFieldReference,      // Type guard for field references
  getFieldPath,          // Get dot/slash path from nested field reference
  transformFilterNode,   // Transform a single AST node (lower-level than transformFilter)
} from '@lokalise/odata-mapper'

Types

import type {
  // Parser types
  ParsedODataFilter,

  // Filter types
  TransformedFilter,
  ComparisonFilter,
  InFilter,
  NotInFilter,
  LogicalFilter,
  NotFilter,
  StringFunctionFilter,

  // Value types
  FilterValue,
  ComparisonOperator,
  LogicalOperator,
  StringFunction,

  // AST node types (for custom transformer logic)
  FilterTreeNode,
  ComparisonNode,
  LogicalNode,
  InNode,
  NotNode,
  FunctionCallNode,
  FieldReference,

  // Utility types
  TransformOptions,
  RawBindValue,
  FieldFilterResult,
  ParsedFilter,
} from '@lokalise/odata-mapper'

Re-exported types from @balena/odata-parser are also available: BindKey, BindReference, ODataBinds, ODataOptions, ODataQuery, PropertyPath, TextBind, NumberBind, BooleanBind, DateBind, FilterOption.

License

Apache-2.0