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

odata2orm

v2.0.0

Published

A universal library to convert OData queries to various ORM filters (Prisma, TypeORM, Sequelize, Mongoose) with full pagination support

Readme

odata2orm

npm version License: ISC TypeScript

Convert OData v4 query ($filter, $top, $skip, $orderby, $select, $count) to ORM filters.

Status

| ORM | Filter | Query builder | Pagination | Notes | |-----------|:------:|:-------------:|:----------:|------------------------------------| | Prisma | ✅ | ✅ | ✅ | Schema validation, nested queries | | TypeORM | ✅ | ✅ | ✅ | Uses FindOperator (peer dep) | | Sequelize | ✅ | ✅ | ✅ | Uses Op symbols (peer dep) | | Mongoose | ✅ | ✅ | ✅ | Native MongoDB $ops |

Install

pnpm add odata2orm        # or npm / yarn
pnpm add typeorm@^0.3.17  # peer dep — recommended >=0.3.17, also works on ^1.0.0
pnpm add sequelize@^6     # peer dep — recommended ^6 (LTS), v5 works, v7-alpha works
pnpm add mongoose@^8      # peer dep — recommended ^8 (latest), ^7 and ^6 also work

Compatibility matrix

| Peer dep | Recommended | Minimum tested | Notes | |------------|----------------|----------------|---------------------------------------------------------------------------------------| | typeorm | ^0.3.17 | 0.3.17 | Uses And / Or FindOperators — both landed in 0.3.10+, stable from 0.3.17. 1.0.0 works (same API). | | sequelize| ^6 (6.37+) | 5.x | Only Op.* symbols used — present since v5. ^7-alpha works. Without it, fallback Symbol.for('sequelize.op.*') keys are emitted (inspectable but won't execute against a real DB). | | mongoose | ^8 (8.x) | 6.x | Output is plain MongoDB operator strings ($eq, $gt, ...) — any driver-compatible Mongoose accepts it. No version coupling. |

Node.js: >=18 recommended (matches the LTS line all three peers target). Prisma needs no peer dep.

Quick start

import { convertToPrisma } from 'odata2orm';

convertToPrisma("name eq 'John' and age gt 25");
// { AND: [{ name: { equals: 'John' }}, { age: { gt: 25 }}] }

Providers

Filter only

import { convertToPrisma } from 'odata2orm';

convertToPrisma("status eq 'active' and age gt 18");
// { AND: [{ status: { equals: 'active' }}, { age: { gt: 18 }}] }

Full query

import { buildPrismaQuery, buildPrismaPagination } from 'odata2orm';

const query = buildPrismaQuery({
  $filter: "department eq 'IT' and salary gt 50000",
  $top: 20,
  $skip: 40,
  $orderby: 'salary desc, name asc',
  $select: 'id,name,salary',
});
// prisma.user.findMany(query)

const { findQuery, countQuery } = buildPrismaPagination(params);

Schema validation (Zod)

import { z } from 'zod';
import { PrismaQueryBuilder } from 'odata2orm';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  profile: z.object({
    address: z.object({ city: z.string() }),
  }),
  orders: z.array(z.object({ total: z.number() })),
});

const builder = new PrismaQueryBuilder({
  schema: UserSchema,
  enableNestedQueries: true,
  allowAllFields: false,
});

const query = builder.buildQuery({
  $filter: "profile/address/city eq 'Seattle' and orders/any(o: o/total gt 100)",
  $select: 'id,name,profile(address(city)),orders(total)',
  $orderby: 'name asc',
});

Express handler

app.get('/api/users', async (req, res) => {
  const { findQuery, countQuery } = builder.buildPaginationQuery(req.query);
  const [users, total] = await Promise.all([
    prisma.user.findMany(findQuery),
    prisma.user.count(countQuery),
  ]);
  res.json(builder.processPaginationResult(users, total, req.query));
});

Install typeorm alongside this lib. Output uses FindOperator instances (Equal, MoreThan, Like, In, ...).

Filter only

import { convertToTypeORM } from 'odata2orm';
// returns FindOptionsWhere<T> | FindOptionsWhere<T>[]

convertToTypeORM("name eq 'John'");
// { name: Equal('John') }

convertToTypeORM("age gt 18 and age lt 65");
// { age: And(MoreThan(18), LessThan(65)) }

convertToTypeORM("status in ('a','b','c')");
// { status: In(['a','b','c']) }

convertToTypeORM("not (age lt 18)");
// { age: Not(LessThan(18)) }

Full query

import { buildTypeOrmQuery, buildTypeOrmPagination } from 'odata2orm';

const query = buildTypeOrmQuery({
  $filter: "status eq 'active'",
  $top: 15,
  $skip: 10,
  $orderby: 'createdAt desc, name asc',
  $select: 'id,name,status',
});
// repo.find(query)
// query.order = { createdAt: 'DESC', name: 'ASC' }
// query.select = ['id','name','status']

const { findQuery, countQuery } = buildTypeOrmPagination(params);
const [rows, total] = await Promise.all([
  repo.find(findQuery),
  repo.count(countQuery),
]);

Semantics

| OData | TypeORM output | |---------------------------------------|-----------------------------------------------| | AND on different fields | merged object | | AND on same field | And(op1, op2) | | OR on same field (auto-optimized) | In([...]) | | OR on different fields | [{...}, {...}] array | | NOT (a AND b) | de Morgan → [{a: Not}, {b: Not}] | | contains / startswith / endswith| Like (or ILike if caseSensitive: false) | | field eq null | IsNull() | | year(date) eq 2024 | And(MoreThanOrEqual(start), LessThan(end)) | | nested path a/b/c | { a: { b: { c: ... } } } |

Install sequelize alongside this lib. Output uses Sequelize.Op symbols. If sequelize is not installed, fallback Symbol.for('sequelize.op.*') keys are used (still inspectable, but you should install the real package for production).

Filter only

import { convertToSequelize } from 'odata2orm';
import { Op } from 'sequelize';

convertToSequelize("name eq 'John'");
// { name: { [Op.eq]: 'John' } }

convertToSequelize("age gt 18 and age lt 65");
// { age: { [Op.gt]: 18, [Op.lt]: 65 } }

convertToSequelize("status in ('a','b','c')");
// { status: { [Op.in]: ['a','b','c'] } }

convertToSequelize("not (age lt 18)");
// { [Op.not]: { age: { [Op.lt]: 18 } } }

Full query

import { buildSequelizeQuery, buildSequelizePagination } from 'odata2orm';

const query = buildSequelizeQuery({
  $filter: "status eq 'active'",
  $top: 15,
  $skip: 10,
  $orderby: 'createdAt desc, name asc',
  $select: 'id,name,status',
});
// Model.findAll(query)
// query.order      = [['createdAt','DESC'], ['name','ASC']]
// query.attributes = ['id','name','status']
// query.limit      = 15
// query.offset     = 10

const { findQuery, countQuery } = buildSequelizePagination(params);
const [rows, total] = await Promise.all([
  Model.findAll(findQuery),
  Model.count(countQuery),
]);

Semantics

| OData | Sequelize output | |----------------------------------------|-----------------------------------------------| | AND on different fields | merged object | | AND on same field | merged op object { [gt]: ..., [lt]: ... } | | OR on same field (auto-optimized) | { [Op.in]: [...] } | | OR on different fields | { [Op.or]: [{...}, {...}] } | | NOT | { [Op.not]: ... } | | contains / startswith / endswith | [Op.like] (or [Op.iLike] if insensitive) | | field eq null | { [Op.is]: null } | | field ne null | { [Op.not]: null } | | year(date) eq 2024 | { [Op.gte]: start, [Op.lt]: end } | | nested path a/b/c | dot key 'a.b.c' (use with include) |

No peer dep needed — output uses plain MongoDB operator strings ($eq, $gt, $in, ...). Feed directly to Model.find().

Filter only

import { convertToMongoose } from 'odata2orm';

convertToMongoose("name eq 'John'");
// { name: 'John' }

convertToMongoose("age gt 18 and age lt 65");
// { age: { $gt: 18, $lt: 65 } }

convertToMongoose("status in ('a','b','c')");
// { status: { $in: ['a','b','c'] } }

convertToMongoose("contains(name, 'jo')", { caseSensitive: false });
// { name: { $regex: 'jo', $options: 'i' } }

convertToMongoose("not (name eq 'John' or age gt 30)");
// { $nor: [{ name: 'John' }, { age: { $gt: 30 } }] }

Full query

import { buildMongooseQuery, buildMongoosePagination } from 'odata2orm';

const query = buildMongooseQuery({
  $filter: "status eq 'active'",
  $top: 15,
  $skip: 10,
  $orderby: 'createdAt desc, name asc',
  $select: 'id,name,status',
});
// Model.find(query.filter).sort(query.sort).skip(query.skip).limit(query.limit).select(query.select)
// query.filter = { status: 'active' }
// query.sort   = { createdAt: -1, name: 1 }
// query.select = { id: 1, name: 1, status: 1 }

const { findQuery, countQuery } = buildMongoosePagination(params);
const [rows, total] = await Promise.all([
  Model.find(findQuery.filter).sort(findQuery.sort).skip(findQuery.skip).limit(findQuery.limit),
  Model.countDocuments(countQuery.filter),
]);

Semantics

| OData | Mongoose / MongoDB output | |----------------------------------------|----------------------------------------------------------| | field eq v | { field: v } (implicit eq) | | field ne v | { field: { $ne: v } } | | AND on different fields | merged object | | AND on same field (different ops) | merged op object { $gt: ..., $lt: ... } | | AND collision (same op same field) | { $and: [{...}, {...}] } | | OR on same field (auto-optimized) | { $in: [...] } | | OR on different fields | { $or: [...] } | | NOT value | { field: { $ne: v } } | | NOT op clause | { field: { $not: { $op: v } } } | | NOT of OR | { $nor: [...] } | | contains / startswith / endswith | $regex (with $options: 'i' if caseSensitive: false)| | field eq null | { field: null } | | year(date) eq 2024 | { field: { $gte: start, $lt: end } } | | nested path a/b/c | dot key 'a.b.c' |

Regex specials in string literals are escaped automatically.


OData operators

| Category | Operators | |------------|---------------------------------------------------------------------------------| | Comparison | eq ne gt ge lt le | | Logical | and or not | | String | contains startswith endswith indexof substringof tolower toupper | | Date | year year + month combo, date range | | Set | in (...) | | Arithmetic | * / + - (rearranged algebraically) |

Auto-optimizations:

  • a eq 'x' or a eq 'y'In(['x','y'])
  • year(date) eq 2024 → range query
  • date ge X and date le Y → range query

Pagination response

{
  "data": [ ... ],
  "count": 150,
  "hasNext": true,
  "hasPrevious": true,
  "totalPages": 15,
  "currentPage": 3,
  "pageSize": 10
}

See PAGINATION.md. For a step-by-step walkthrough see USAGE.md.

Limitations

Need raw SQL (or aggregation pipeline for Mongo): length(), round(), floor(), ceiling(), month() / day() extraction, complex subqueries.

Develop

pnpm install
pnpm test
pnpm run build
pnpm run lint

License

ISC — datluong2409