init
This commit is contained in:
548
packages/server/src/utils/utils.query.ts
Normal file
548
packages/server/src/utils/utils.query.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import { type Knex } from 'knex';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Escapes a JSON key for use in PostgreSQL JSON operators.
|
||||
* Escapes single quotes by doubling them, which is the PostgreSQL standard.
|
||||
*/
|
||||
const escapeJsonKey = (key: string): string => {
|
||||
return key.replace(/'/g, "''");
|
||||
};
|
||||
|
||||
const getFieldSelector = (query: Knex.QueryBuilder, field: string[], tableName?: string) => {
|
||||
const baseColumn = field[0];
|
||||
if (field.length === 1) {
|
||||
return tableName ? `${tableName}.${baseColumn}` : baseColumn;
|
||||
}
|
||||
|
||||
const baseFieldRef = tableName ? query.client.ref(baseColumn).withSchema(tableName) : query.client.ref(baseColumn);
|
||||
const jsonPath = field.slice(1);
|
||||
let sqlExpression = baseFieldRef.toString();
|
||||
|
||||
for (let i = 0; i < jsonPath.length - 1; i++) {
|
||||
const escapedKey = escapeJsonKey(jsonPath[i]);
|
||||
sqlExpression += ` -> '${escapedKey}'`;
|
||||
}
|
||||
|
||||
const finalElement = jsonPath[jsonPath.length - 1];
|
||||
const escapedFinalKey = escapeJsonKey(finalElement);
|
||||
sqlExpression += ` ->> '${escapedFinalKey}'`;
|
||||
return query.client.raw(sqlExpression);
|
||||
};
|
||||
|
||||
const queryConditionTextSchema = z
|
||||
.object({
|
||||
type: z.literal('text'),
|
||||
tableName: z.string().optional(),
|
||||
field: z.array(z.string()),
|
||||
conditions: z.object({
|
||||
equal: z.string().nullish(),
|
||||
notEqual: z.string().optional(),
|
||||
like: z.string().optional(),
|
||||
notLike: z.string().optional(),
|
||||
in: z.array(z.string()).optional(),
|
||||
notIn: z.array(z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
example: {
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
summary: 'Equal condition',
|
||||
value: {
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
summary: 'Like condition',
|
||||
value: {
|
||||
type: 'text',
|
||||
field: ['content'],
|
||||
conditions: {
|
||||
like: '%cat%',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
summary: 'In condition',
|
||||
value: {
|
||||
type: 'text',
|
||||
field: ['type'],
|
||||
conditions: {
|
||||
in: ['demo', 'article', 'post'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
summary: 'Null check',
|
||||
value: {
|
||||
type: 'text',
|
||||
field: ['source'],
|
||||
conditions: {
|
||||
equal: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
type QueryConditionText = z.infer<typeof queryConditionTextSchema>;
|
||||
|
||||
const applyQueryConditionText = (query: Knex.QueryBuilder, { field, tableName, conditions }: QueryConditionText) => {
|
||||
const selector = getFieldSelector(query, field, tableName);
|
||||
if (conditions.equal) {
|
||||
query = query.where(selector, '=', conditions.equal);
|
||||
}
|
||||
if (conditions.notEqual) {
|
||||
query = query.where(selector, '<>', conditions.notEqual);
|
||||
}
|
||||
if (conditions.like) {
|
||||
query = query.whereLike(selector, conditions.like);
|
||||
}
|
||||
if (conditions.notLike) {
|
||||
query = query.not.whereLike(selector, conditions.notLike);
|
||||
}
|
||||
if (conditions.equal === null) {
|
||||
query = query.whereNull(selector);
|
||||
}
|
||||
if (conditions.notEqual === null) {
|
||||
query = query.whereNotNull(selector);
|
||||
}
|
||||
if (conditions.in) {
|
||||
query = query.whereIn(selector, conditions.in);
|
||||
}
|
||||
if (conditions.notIn) {
|
||||
query = query.whereNotIn(selector, conditions.notIn);
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
const queryConditionNumberSchema = z
|
||||
.object({
|
||||
type: z.literal('number'),
|
||||
tableName: z.string().optional(),
|
||||
field: z.array(z.string()),
|
||||
conditions: z.object({
|
||||
equals: z.number().nullish(),
|
||||
notEquals: z.number().nullish(),
|
||||
greaterThan: z.number().optional(),
|
||||
greaterThanOrEqual: z.number().optional(),
|
||||
lessThan: z.number().optional(),
|
||||
lessThanOrEqual: z.number().optional(),
|
||||
in: z.array(z.number()).optional(),
|
||||
notIn: z.array(z.number()).optional(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
example: {
|
||||
type: 'number',
|
||||
field: ['typeVersion'],
|
||||
conditions: {
|
||||
equals: 1,
|
||||
},
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
summary: 'Equals condition',
|
||||
value: {
|
||||
type: 'number',
|
||||
field: ['typeVersion'],
|
||||
conditions: {
|
||||
equals: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
summary: 'Greater than condition',
|
||||
value: {
|
||||
type: 'number',
|
||||
field: ['typeVersion'],
|
||||
conditions: {
|
||||
greaterThan: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
summary: 'Range condition',
|
||||
value: {
|
||||
type: 'number',
|
||||
field: ['typeVersion'],
|
||||
conditions: {
|
||||
greaterThanOrEqual: 1,
|
||||
lessThanOrEqual: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
summary: 'In condition',
|
||||
value: {
|
||||
type: 'number',
|
||||
field: ['typeVersion'],
|
||||
conditions: {
|
||||
in: [1, 2, 3],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
type QueryConditionNumber = z.infer<typeof queryConditionNumberSchema>;
|
||||
|
||||
const applyQueryConditionNumber = (
|
||||
query: Knex.QueryBuilder,
|
||||
{ field, tableName, conditions }: QueryConditionNumber,
|
||||
) => {
|
||||
const selector = getFieldSelector(query, field, tableName);
|
||||
if (conditions.equals !== undefined && conditions.equals !== null) {
|
||||
query = query.where(selector, '=', conditions.equals);
|
||||
}
|
||||
if (conditions.notEquals !== undefined && conditions.notEquals !== null) {
|
||||
query = query.where(selector, '<>', conditions.notEquals);
|
||||
}
|
||||
if (conditions.equals === null) {
|
||||
query = query.whereNull(selector);
|
||||
}
|
||||
if (conditions.notEquals === null) {
|
||||
query = query.whereNotNull(selector);
|
||||
}
|
||||
if (conditions.greaterThan) {
|
||||
query = query.where(selector, '>', conditions.greaterThan);
|
||||
}
|
||||
if (conditions.greaterThanOrEqual) {
|
||||
query = query.where(selector, '>=', conditions.greaterThanOrEqual);
|
||||
}
|
||||
if (conditions.lessThan) {
|
||||
query = query.where(selector, '<', conditions.lessThan);
|
||||
}
|
||||
if (conditions.lessThanOrEqual) {
|
||||
query = query.where(selector, '<=', conditions.lessThanOrEqual);
|
||||
}
|
||||
if (conditions.in) {
|
||||
query = query.whereIn(selector, conditions.in);
|
||||
}
|
||||
if (conditions.notIn) {
|
||||
query = query.whereNotIn(selector, conditions.notIn);
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
const queryConditionSchema = z.discriminatedUnion('type', [queryConditionTextSchema, queryConditionNumberSchema]);
|
||||
|
||||
type QueryCondition = z.infer<typeof queryConditionSchema>;
|
||||
|
||||
const applyQueryCondition = (query: Knex.QueryBuilder, options: QueryCondition) => {
|
||||
switch (options.type) {
|
||||
case 'text': {
|
||||
return applyQueryConditionText(query, options);
|
||||
}
|
||||
case 'number': {
|
||||
return applyQueryConditionNumber(query, options);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown filter type`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type QueryFilter = QueryCondition | QueryOperator;
|
||||
|
||||
type QueryOperator = {
|
||||
type: 'operator';
|
||||
operator: 'and' | 'or';
|
||||
conditions: QueryFilter[];
|
||||
};
|
||||
|
||||
// Create a depth-limited recursive schema for OpenAPI compatibility
|
||||
// This supports up to 3 levels of nesting, which should be sufficient for most use cases
|
||||
// OpenAPI cannot handle z.lazy(), so we manually define the nesting
|
||||
// If you need deeper nesting, you can add more levels (Level3, Level4, etc.)
|
||||
const queryFilterSchemaLevel0: z.ZodType<QueryFilter> = z.union([
|
||||
queryConditionSchema,
|
||||
z
|
||||
.object({
|
||||
type: z.literal('operator'),
|
||||
operator: z.enum(['and', 'or']),
|
||||
conditions: z.array(queryConditionSchema),
|
||||
})
|
||||
.meta({
|
||||
example: {
|
||||
type: 'operator',
|
||||
operator: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
summary: 'AND operator',
|
||||
value: {
|
||||
type: 'operator',
|
||||
operator: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['type'],
|
||||
conditions: {
|
||||
equal: 'demo',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
summary: 'OR operator',
|
||||
value: {
|
||||
type: 'operator',
|
||||
operator: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'baz',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const queryFilterSchemaLevel1: z.ZodType<QueryFilter> = z.union([
|
||||
queryConditionSchema,
|
||||
z
|
||||
.object({
|
||||
type: z.literal('operator'),
|
||||
operator: z.enum(['and', 'or']),
|
||||
conditions: z.array(queryFilterSchemaLevel0),
|
||||
})
|
||||
.meta({
|
||||
example: {
|
||||
type: 'operator',
|
||||
operator: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'operator',
|
||||
operator: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'baz',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
summary: 'Nested AND within OR',
|
||||
value: {
|
||||
type: 'operator',
|
||||
operator: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'operator',
|
||||
operator: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['type'],
|
||||
conditions: {
|
||||
equal: 'demo',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'baz',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const queryFilterSchemaLevel2: z.ZodType<QueryFilter> = z.union([
|
||||
queryConditionSchema,
|
||||
z
|
||||
.object({
|
||||
type: z.literal('operator'),
|
||||
operator: z.enum(['and', 'or']),
|
||||
conditions: z.array(queryFilterSchemaLevel1),
|
||||
})
|
||||
.meta({
|
||||
example: {
|
||||
type: 'operator',
|
||||
operator: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'operator',
|
||||
operator: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'baz',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['type'],
|
||||
conditions: {
|
||||
equal: 'demo',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
summary: 'Complex nested query',
|
||||
value: {
|
||||
type: 'operator',
|
||||
operator: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'operator',
|
||||
operator: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'bar',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['metadata', 'foo'],
|
||||
conditions: {
|
||||
equal: 'baz',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: ['type'],
|
||||
conditions: {
|
||||
equal: 'demo',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
// Export the depth-limited schema (supports 3 levels of nesting)
|
||||
// This works with OpenAPI schema generation
|
||||
const queryFilterSchema = queryFilterSchemaLevel2;
|
||||
|
||||
const applyQueryFilter = (query: Knex.QueryBuilder, filter: QueryFilter) => {
|
||||
if (filter.type === 'operator') {
|
||||
if (filter.conditions.length === 0) {
|
||||
return query;
|
||||
}
|
||||
|
||||
switch (filter.operator) {
|
||||
case 'or': {
|
||||
return query.where((subquery) => {
|
||||
let isFirst = true;
|
||||
for (const condition of filter.conditions) {
|
||||
if (isFirst) {
|
||||
applyQueryFilter(subquery, condition);
|
||||
isFirst = false;
|
||||
} else {
|
||||
subquery.orWhere((subSubquery) => {
|
||||
applyQueryFilter(subSubquery, condition);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
case 'and': {
|
||||
return query.where((subquery) => {
|
||||
let isFirst = true;
|
||||
for (const condition of filter.conditions) {
|
||||
if (isFirst) {
|
||||
applyQueryFilter(subquery, condition);
|
||||
isFirst = false;
|
||||
} else {
|
||||
subquery.andWhere((subSubquery) => {
|
||||
applyQueryFilter(subSubquery, condition);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return applyQueryCondition(query, filter);
|
||||
}
|
||||
};
|
||||
|
||||
export { applyQueryCondition, queryConditionSchema, queryFilterSchema, applyQueryFilter };
|
||||
Reference in New Issue
Block a user