A universal query builder that extends Prisma's capabilities while preserving familiar WHERE and ORDER BY API. Generate parameterized SQL queries for PostgreSQL through $queryRaw with advanced JSON/JSONB operations that standard Prisma doesn't support.
Goal: Replicate Prisma's query interface using $queryRaw while adding powerful JSON functionality.
- 🎯 Prisma-compatible API - Familiar
whereandorderBysyntax from Prisma - 🔍 JSON Path queries - Access nested data with
['key', 'nested']or wildcard patterns - 🌟 Wildcard support - Query arrays with
*, array indices[0],[-1] - 🚀 JSON sorting - Order by nested JSON fields with type casting and aggregations
- 🔧 Extended filters - All Prisma filters plus advanced JSON operations
- 🔎 Full-text search - PostgreSQL FTS in JSON with language support and phrase matching
- 🎨 Logical operators - AND, OR, NOT combinations just like Prisma
- 📦 Prisma native - Uses
Prisma.Sqlfor safe parameterized queries - ⚡ Universal - Works with any table structure, not schema-dependent
| Feature | Standard Prisma | This Library |
|---|---|---|
| Basic filters | contains, gt, gte, in, etc. |
✅ Same API |
| Logical operators | AND, OR, NOT |
✅ Same API |
| JSON field access | metadata: { path: ['key'], equals: 'value' } |
✅ Same + wildcards |
| JSON array queries | Limited | ✅ path: ['tags', '*', 'name'] |
| Array indices | ❌ Not supported | ✅ path: ['items', 0] or 'items[0]' |
| Negative indices | ❌ Not supported | ✅ path: ['items', -1] or 'items[-1]' |
| JSON sorting | ❌ Not supported | ✅ With type casting and aggregations |
| Full-text search | ❌ Not in JSON | ✅ PostgreSQL FTS with language support |
| Wildcard paths | ❌ Not supported | ✅ 'tags[*].name' syntax |
| Path formats | Array only | ✅ Array ['a', 'b'] or string 'a.b' |
| Query method | ORM methods | $queryRaw with generated SQL |
npm install @revisium/prisma-pg-jsonBefore using the library, you must configure it with your Prisma client:
import { configurePrisma } from '@revisium/prisma-pg-json';
import { Prisma } from '@prisma/client';
// Configure once at application startup
configurePrisma(Prisma);Important: Call
configurePrisma()before using any query building functions. This allows the library to work with any Prisma client version and custom import paths.
For simple cases, buildQuery() assembles a complete SELECT ... WHERE ... ORDER BY ... LIMIT ... OFFSET query in one call:
import { buildQuery, configurePrisma } from '@revisium/prisma-pg-json';
import { Prisma, PrismaClient } from '@prisma/client';
configurePrisma(Prisma);
const prisma = new PrismaClient();
const sql = buildQuery({
tableName: 'users',
tableAlias: 'u', // optional, defaults to first char of tableName
fields: ['id', 'name', 'age'], // optional, defaults to ['*']
fieldConfig: {
name: 'string',
age: 'number',
isActive: 'boolean',
metadata: 'json',
},
where: {
name: { contains: 'john', mode: 'insensitive' },
age: { gte: 18 },
metadata: { path: 'role', equals: 'admin' },
},
orderBy: [
{ age: 'desc' },
{ metadata: { path: 'score', type: 'int', direction: 'asc' } },
],
take: 20, // default: 50
skip: 0, // default: 0
});
const results = await prisma.$queryRaw(sql);For more control (JOINs, CTEs, keyset pagination), use the individual functions to build SQL fragments:
import { generateWhere, generateOrderBy, FieldConfig } from '@revisium/prisma-pg-json';
import { PrismaClient, Prisma } from '@prisma/client';
import { configurePrisma } from '@revisium/prisma-pg-json';
// Configure the library
configurePrisma(Prisma);
const prisma = new PrismaClient();
// Define your field types (FieldConfig maps field names to types)
const fieldConfig: FieldConfig = {
id: 'string', // FieldType = 'string' | 'number' | 'boolean' | 'date' | 'json'
name: 'string',
age: 'number',
isActive: 'boolean',
createdAt: 'date',
metadata: 'json',
};
// Generate WHERE clause
const whereClause = generateWhere({
where: {
name: { contains: 'john', mode: 'insensitive' },
age: { gte: 18 },
},
fieldConfig,
tableAlias: 'u',
});
// Generate ORDER BY clause
const orderByClause = generateOrderBy({
orderBy: [
{ name: 'asc' },
{ createdAt: 'desc' }
],
fieldConfig,
tableAlias: 'u',
});
// Use in raw SQL query
const query = Prisma.sql`
SELECT * FROM users u
WHERE ${whereClause}
${orderByClause}
`;
const results = await prisma.$queryRaw(query);Access nested JSON data with flexible path syntax - use string or array notation:
const whereClause = generateWhere({
where: {
// Path as array (recommended for complex paths)
metadata: {
path: ['settings', 'theme'],
equals: 'dark'
},
// Path as string with dot notation
metadata: {
path: 'user.preferences.language',
equals: 'en'
},
// Array wildcard - matches any array element
metadata: {
path: ['tags', '*', 'name'],
string_contains: 'important'
},
// String path with wildcards
metadata: {
path: 'items[*].status',
equals: 'active'
},
// Array indices with array notation
metadata: {
path: ['items', 0, 'status'],
equals: 'active'
},
// Array indices with string notation
metadata: {
path: 'items[0].price',
gte: 100
},
// Negative indices (from end)
metadata: {
path: ['history', -1, 'action'],
equals: 'created'
},
// Negative indices with string notation
metadata: {
path: 'reviews[-1].rating',
gte: 4
},
// Array contains object
metadata: {
path: ['reviews'],
array_contains: { rating: 5, verified: true }
}
},
fieldConfig: { metadata: 'json' },
tableAlias: 't'
});Sort by nested JSON fields with type casting and aggregations. When sorting by JSON arrays, use aggregations to determine sort value:
Supported types: text, int, float, boolean, timestamp
Supported aggregations (for JSON arrays): min, max, avg, first, last
const orderByClause = generateOrderBy({
orderBy: [
// Sort by nested JSON number (array path)
{
metadata: {
path: ['stats', 'score'],
direction: 'desc',
type: 'float'
}
},
// Sort by nested JSON date (string path)
{
metadata: {
path: 'timestamps.lastActivity',
direction: 'desc',
type: 'timestamp'
}
},
// Sort by array element (array notation)
{
metadata: {
path: ['priorities', 0],
direction: 'asc',
type: 'int'
}
},
// Sort by array element (string notation)
{
metadata: {
path: 'ratings[0].value',
direction: 'desc',
type: 'float'
}
},
// Sort by array aggregation - average
{
metadata: {
path: 'scores',
direction: 'desc',
type: 'float',
aggregation: 'avg'
}
},
// Sort by array aggregation - max value
{
metadata: {
path: ['reviews', '*', 'rating'],
direction: 'desc',
type: 'int',
aggregation: 'max'
}
},
// Sort by first element
{
metadata: {
path: 'tags',
direction: 'desc',
aggregation: 'first'
}
},
// Sort by last element
{
metadata: {
path: ['history', '*', 'timestamp'],
direction: 'desc',
type: 'timestamp',
aggregation: 'last'
}
}
],
fieldConfig: { metadata: 'json' },
tableAlias: 't'
});Complete list of available filters for each field type:
name: {
equals: 'john', // Exact match
not: 'admin', // Not equal
contains: 'john', // Contains substring
startsWith: 'mr', // Starts with
endsWith: 'gmail.com', // Ends with
in: ['active', 'pending'], // In array
notIn: ['deleted', 'banned'], // Not in array
mode: 'insensitive' // Case-insensitive (works with contains, startsWith, endsWith)
}age: {
equals: 25, // Exact match
not: 0, // Not equal
gt: 18, // Greater than
gte: 18, // Greater than or equal
lt: 65, // Less than
lte: 65, // Less than or equal
in: [18, 25, 30], // In array
notIn: [0, 999] // Not in array
}isActive: true, // Direct boolean value
isDeleted: {
equals: false, // Explicit equals
not: true // Not equal
}createdAt: {
equals: new Date('2024-01-01'), // Exact date
not: new Date('2024-01-01'), // Not equal
gt: new Date('2024-01-01'), // After date
gte: new Date('2024-01-01'), // After or equal
lt: new Date('2025-01-01'), // Before date
lte: new Date('2025-01-01'), // Before or equal
in: [date1, date2], // In array
notIn: [date3, date4] // Not in array
}metadata: {
path: ['config', 'enabled'], // Required: JSON path (string | string[])
// Value comparisons
equals: true, // Exact match
not: false, // Not equal
// String operations (on JSON strings)
string_contains: 'text', // Contains substring
string_starts_with: 'prefix', // Starts with
string_ends_with: 'suffix', // Ends with
// Full-text search (PostgreSQL FTS)
search: 'database performance', // Full-text search with stemming
searchLanguage: 'english', // Language config ('simple', 'english', 'russian', etc.)
searchType: 'plain', // 'plain' (AND) or 'phrase' (exact phrase)
searchIn: 'values', // 'all' (default), 'values', 'keys', 'strings', 'numbers', 'booleans'
// Number/Date operations (on JSON numbers/dates)
gt: 10, // Greater than
gte: 10, // Greater than or equal
lt: 100, // Less than
lte: 100, // Less than or equal
// Array operations (for JSON arrays)
array_contains: ['value1', 'value2'], // Array contains ALL specified elements (always array)
array_starts_with: 'first_value', // First array element equals value
array_ends_with: 'last_value', // Last array element equals value
// Array/object membership
in: ['value1', 'value2'], // Value in array
notIn: ['value3', 'value4'], // Value not in array
// Case sensitivity (works with string operations)
mode: 'insensitive' // Case-insensitive matching
}PostgreSQL full-text search (FTS) with support for different languages, search types, and recursive path searching:
const whereClause = generateWhere({
where: {
// Search across entire JSON document (root level)
metadata: {
path: '', // or path: []
search: 'postgresql database',
},
// Search in specific field
metadata: {
path: 'content',
search: 'machine learning',
},
// Search in nested field
metadata: {
path: 'article.body',
search: 'artificial intelligence',
},
// Search in array (searches all elements recursively)
metadata: {
path: 'tags',
search: 'javascript',
},
// Search with specific language (better stemming)
metadata: {
path: 'description',
search: 'running quickly',
searchLanguage: 'english', // 'simple', 'english', 'russian', 'french', etc.
},
// Phrase search (exact phrase match)
metadata: {
path: 'text',
search: 'full-text search',
searchType: 'phrase', // words must appear in this exact order
},
// Plain search (default - all words must be present, AND logic)
metadata: {
path: 'content',
search: 'database performance',
searchType: 'plain', // both 'database' AND 'performance' must be present
},
// Search only in values (exclude JSON keys from search)
metadata: {
path: '',
search: 'Anton',
searchIn: 'values', // searches only values, not field names
},
// Search only in JSON keys (field names)
metadata: {
path: '',
search: 'username',
searchIn: 'keys', // searches only field names
},
// Search only in string values
metadata: {
path: 'data',
search: 'text',
searchIn: 'strings', // only string values
},
// Search only in numeric values
metadata: {
path: 'stats',
search: '42',
searchIn: 'numbers', // only numeric values
},
// Search only in boolean values
metadata: {
path: 'flags',
search: 'true',
searchIn: 'booleans', // only boolean values
},
},
fieldConfig: { metadata: 'json' },
tableAlias: 't'
});Search Types:
plain(default): Usesplainto_tsquery- all words must be present (AND logic)'database performance'→ finds docs with both "database" AND "performance"
phrase: Usesphraseto_tsquery- exact phrase in order'full text search'→ finds only exact phrase "full text search"
Search Languages:
'simple'(default): No stemming, works with any language (UTF-8)'english': English stemming (running → run)'russian': Russian stemming'french','german','spanish', etc.: Language-specific stemming
Search Scope (searchIn):
'all'(default): Searches in JSON keys + all values (strings, numbers, booleans)'values': Searches only in values (strings + numbers + booleans), excludes field names'keys': Searches only in JSON field names, excludes values'strings': Searches only in string values'numbers': Searches only in numeric values'booleans': Searches only in boolean values (true/false)
Note: null values are not indexed by PostgreSQL FTS and cannot be searched.
Path Behavior:
- Searches are always recursive - searches the specified path and all nested levels
- Empty path
''or[]: searches entire JSON document - Specific path
'content': searches in that field and all nested structures - Array path
'tags': searches across all array elements recursively
// Find documents about "PostgreSQL" OR "MySQL" - use OR operator
{
OR: [
{ metadata: { path: '', search: 'PostgreSQL' } },
{ metadata: { path: '', search: 'MySQL' } }
]
}
// Find documents with "database" in title AND "performance" in content
{
AND: [
{ metadata: { path: 'title', search: 'database' } },
{ metadata: { path: 'content', search: 'performance' } }
]
}
// Combine FTS with other filters
{
AND: [
{ metadata: { path: '', search: 'javascript' } },
{ metadata: { path: 'published', equals: true } },
{ metadata: { path: 'rating', gte: 4 } }
]
}Combine filters with AND, OR, NOT:
const whereClause = generateWhere({
where: {
AND: [
{ age: { gte: 18 } },
{
OR: [
{ status: 'active' },
{ priority: { gte: 5 } }
]
}
],
NOT: {
email: { contains: 'spam' }
}
},
fieldConfig,
tableAlias: 'u'
});Standard Prisma only supports simple JSON access. This library provides:
- Wildcard queries:
tags[*].nameto search all array elements - Array indices:
items[0],items[-1]for specific positions - Deep nesting: Complex paths like
['user', 'settings', 'preferences', 'theme'] - Array operations: Check if arrays contain specific objects
Prisma doesn't support sorting by JSON fields. This library enables:
- Sort by nested JSON values with proper type casting
- Multiple JSON sorts with different types (numeric, text, date)
- Array element sorting
Standard OFFSET-based pagination loses rows when data has duplicate sort values or when rows are inserted/deleted between pages. Keyset (cursor-based) pagination solves this by using the last seen row's values to determine the starting point for the next page.
The library provides utilities for building keyset pagination with multi-column sort support:
import {
Prisma,
encodeCursor,
decodeCursor,
computeSortHash,
extractCursorValues,
OrderByPart,
} from '@revisium/prisma-pg-json';
// OrderByPart describes each sort column
const parts: OrderByPart[] = [
{
expression: Prisma.sql`r."createdAt"`,
direction: 'DESC',
fieldName: 'createdAt',
isJson: false,
},
];
// Compute a hash of the sort configuration (detects sort changes between pages)
const sortHash = computeSortHash(parts);
// Extract cursor values from a row
const row = { createdAt: new Date('2025-01-01'), versionId: 'v-123' };
const values = extractCursorValues(row, parts);
// → ['2025-01-01T00:00:00.000Z']
// Encode into an opaque cursor string
const cursor = encodeCursor(values, 'v-123', sortHash);
// Decode back
const decoded = decodeCursor(cursor);
// → { values: ['2025-01-01T00:00:00.000Z'], tiebreaker: 'v-123', sortHash: '...' }buildKeysetCondition generates a multi-column WHERE clause that skips past the cursor position. It handles mixed ASC/DESC directions and NULL values:
import { buildKeysetCondition } from '@revisium/prisma-pg-json';
const condition = buildKeysetCondition(
parts, // OrderByPart[] — sort columns
decoded.values, // cursor values from decodeCursor
decoded.tiebreaker, // tiebreaker value (e.g., row ID)
Prisma.sql`r."versionId"`, // tiebreaker SQL expression
);
// Use in your query
const query = Prisma.sql`
SELECT * FROM rows r
WHERE r."tableVersionId" = ${tableVersionId}
AND ${condition}
ORDER BY r."createdAt" DESC, r."versionId" DESC
LIMIT ${take + 1}
`;generateOrderByParts (from the ORDER BY module) produces the OrderByPart[] array needed by the keyset utilities:
import { generateOrderByParts } from '@revisium/prisma-pg-json';
const parts = generateOrderByParts({
tableAlias: 'r',
orderBy: [{ createdAt: 'desc' }, { data: { path: 'priority', type: 'int', direction: 'asc' } }],
fieldConfig: { createdAt: 'date', data: 'json' },
});
const sortHash = computeSortHash(parts);
const cursorValues = extractCursorValues(lastRow, parts);
const cursor = encodeCursor(cursorValues, lastRow.id, sortHash);Works with any table structure - just define your field types and start querying.
Query nested JSON structures across multiple tables using CTE-based SQL with wildcard array support. Designed for schemas where JSONB fields contain $ref-style references to other data.
import {
buildSubSchemaCte,
buildSubSchemaWhere,
buildSubSchemaOrderBy,
buildSubSchemaQuery,
buildSubSchemaCountQuery,
parsePath,
MAX_TAKE,
MAX_SKIP,
} from '@revisium/prisma-pg-json';Build CTE, WHERE, and ORDER BY separately for full control:
// 1. Build the CTE that extracts sub-schema items
const cte = buildSubSchemaCte({
tables: [
{
tableId: 'characters',
tableVersionId: 'ver_123',
paths: [
{ path: 'avatar' }, // simple field
{ path: 'gallery[*]' }, // array with wildcard
{ path: 'items[*].image' }, // nested in array
],
},
],
});
// 2. Build WHERE clause
const whereClause = buildSubSchemaWhere({
where: {
tableId: 'characters',
data: { path: 'status', equals: 'uploaded' },
},
tableAlias: 's', // optional, for JOINed queries
});
// 3. Build ORDER BY clause
const orderByClause = buildSubSchemaOrderBy({
orderBy: [{ rowId: 'asc' }, { fieldPath: 'asc' }],
tableAlias: 's',
});
// 4. Compose the final query
const query = Prisma.sql`
${cte}
SELECT s.* FROM sub_schema_items s
${whereClause}
${orderByClause}
LIMIT 50 OFFSET 0
`;For simple cases, buildSubSchemaQuery and buildSubSchemaCountQuery combine everything:
const query = buildSubSchemaQuery({
tables: [{ tableId: 'characters', tableVersionId: 'ver_123', paths: [{ path: 'avatar' }] }],
where: { data: { path: 'type', equals: 'image' } },
orderBy: [{ rowId: 'asc' }],
take: 50,
skip: 0,
});
const countQuery = buildSubSchemaCountQuery({
tables: [{ tableId: 'characters', tableVersionId: 'ver_123', paths: [{ path: 'avatar' }] }],
where: { data: { path: 'type', equals: 'image' } },
});The path field uses dot-notation for nested objects and [*] for arrays:
| Path | Description | Generated SQL |
|---|---|---|
'avatar' |
Top-level field | data->'avatar' |
'profile.photo' |
Nested object | data->'profile'->'photo' |
'gallery[*]' |
Array elements | jsonb_array_elements(data->'gallery') |
'items[*].image' |
Field inside array | CROSS JOIN LATERAL with element access |
MAX_TAKE = 10000— maximum rows per pageMAX_SKIP = 1000000— maximum offset
Parse, convert, and validate JSON path strings:
import { parseJsonPath, arrayToJsonPath, validateJsonPath } from '@revisium/prisma-pg-json';
// Parse string path to array of segments
parseJsonPath('user.profile.name') // ['user', 'profile', 'name']
parseJsonPath('items[0].price') // ['items', '0', 'price']
parseJsonPath('tags[*].name') // ['tags', '*', 'name']
parseJsonPath('items[-1]') // ['items', 'last']
// Convert array back to string path
arrayToJsonPath(['user', 'name']) // 'user.name'
arrayToJsonPath(['items', '0', 'price']) // 'items[0].price'
arrayToJsonPath(['tags', '*', 'name']) // 'tags[*].name'
// Validate path syntax
validateJsonPath('user.email') // { isValid: true }
validateJsonPath('items[') // { isValid: false, error: 'Unclosed bracket in JSON path' }
validateJsonPath('') // { isValid: false, error: 'JSON path cannot be empty' }parseJsonPath accepts both string and array input — if passed an array, it returns it unchanged.
src/
├── index.ts # Public exports
├── prisma-adapter.ts # Proxy pattern — no direct @prisma/client dependency
├── query-builder.ts # buildQuery() — complete SELECT builder
├── types.ts # Filter, OrderBy, FieldConfig types
│
├── where/ # WHERE clause generation
│ ├── string.ts # String filters (equals, contains, startsWith, ...)
│ ├── number.ts # Number filters (gt, gte, lt, lte, in, ...)
│ ├── boolean.ts # Boolean filters (equals, not)
│ ├── date.ts # Date filters (gt, gte, lt, lte, in, ...)
│ └── json/ # JSON/JSONB filters — strategy pattern
│ ├── json-filter.ts # Entry point, path validation
│ ├── operator-manager.ts # Operator registry and routing
│ ├── operators/ # 14 operator classes (equals, gt, search, ...)
│ │ └── base-operator.ts # Abstract base class
│ └── jsonpath/ # PostgreSQL jsonpath SQL helpers
│
├── orderBy/ # ORDER BY clause generation
│ └── generateOrderBy.ts # generateOrderBy, generateOrderByClauses, generateOrderByParts
│
├── keyset/ # Cursor-based pagination
│ ├── cursor.ts # encodeCursor, decodeCursor, computeSortHash, extractCursorValues
│ └── condition.ts # buildKeysetCondition — multi-column WHERE
│
├── sub-schema/ # CTE queries for nested JSON schemas
│ ├── sub-schema-builder.ts # buildSubSchemaCte, buildSubSchemaWhere, buildSubSchemaOrderBy, ...
│ └── types.ts # SubSchemaTableConfig, SubSchemaWhereInput, ...
│
└── utils/
├── parseJsonPath.ts # parseJsonPath, arrayToJsonPath, validateJsonPath
└── sql-jsonpath.ts # jsonb_path_exists SQL helpers
buildQuery(options)
├── generateWhere(where, fieldConfig, tableAlias)
│ ├── generateStringFilter(fieldRef, filter)
│ ├── generateNumberFilter(fieldRef, filter)
│ ├── generateBooleanFilter(fieldRef, filter)
│ ├── generateDateFilter(fieldRef, filter)
│ └── generateJsonFilter(fieldRef, filter)
│ └── OperatorManager → BaseOperator subclasses
│
├── generateOrderBy(orderBy, fieldConfig, tableAlias)
│ └── processJsonOrder → type casting + aggregation subqueries
│
└── Prisma.sql`SELECT ... WHERE ... ORDER BY ... LIMIT ... OFFSET ...`
- All user values parameterized via
Prisma.sqltagged templates Prisma.raw()used for SQL identifiers (table aliases, field names, sort directions) — validated at runtime against whitelists where applicable (VALID_DIRECTIONS,VALID_TYPES,VALID_AGGREGATIONS,SEARCH_LANGUAGES). Table/field names come from consumer-providedfieldConfig, not user input.- Path traversal (
..) rejected in JSON path validation - 40+ SQL injection attack test scenarios
MIT