@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
Maintainers
Keywords
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-parserQuick 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 nullextractInValues<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 ErrorextractStringFunction(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']))
// undefinedBulk 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 matchSupported Filter Types
Comparison Operators
eq- Equalne- Not equalgt- Greater thange- Greater than or equallt- Less thanle- Less than or equal
Logical Operators
and- All conditions must matchor- Any condition can matchnot- 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.
