Add reporting features with new report engine and bookmark management
- Introduced a comprehensive report engine for generating and managing reports, including sales and logistics reports. - Added new API endpoints for retrieving report configurations, data, and metadata, ensuring secure access with privilege checks. - Implemented a report bookmarks system to allow users to save and manage report filters and configurations. - Created database migrations for the `report_bookmarks` table and updated the schema to support new report functionalities. - Developed services and controllers for handling report queries and bookmarks, including CRUD operations for bookmarks. - Enhanced API documentation to reflect the new reporting features and endpoints. - Added unit and integration tests to validate the new functionalities and ensure data integrity across report operations.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { DATA_FORMAT } from '../constants/data-format';
|
||||
import { formatReportCell, formatReportRow } from './report-cell.formatter';
|
||||
|
||||
describe('report cell formatter', () => {
|
||||
it('formats currency null as zero', () => {
|
||||
expect(formatReportCell(null, DATA_FORMAT.CURRENCY)).toBe('0.0000');
|
||||
});
|
||||
|
||||
it('formats boolean yes/no', () => {
|
||||
expect(formatReportCell(true, DATA_FORMAT.BOOLEAN)).toBe('Yes');
|
||||
expect(formatReportCell(0, DATA_FORMAT.BOOLEAN)).toBe('No');
|
||||
});
|
||||
|
||||
it('formats uppercase text', () => {
|
||||
expect(formatReportCell('draft', DATA_FORMAT.TEXT_UPPERCASE)).toBe('DRAFT');
|
||||
});
|
||||
|
||||
it('formats row by column config', () => {
|
||||
const row = formatReportRow(
|
||||
{ main__status: 'active', main__amount: '10.5' },
|
||||
[
|
||||
{
|
||||
column: 'main__status',
|
||||
query: 'main.status',
|
||||
label: 'Status',
|
||||
type: 'dimension',
|
||||
format: DATA_FORMAT.TEXT_UPPERCASE,
|
||||
},
|
||||
{
|
||||
column: 'main__amount',
|
||||
query: 'main.amount',
|
||||
label: 'Amount',
|
||||
type: 'measure',
|
||||
format: DATA_FORMAT.CURRENCY,
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(row.main__status).toBe('ACTIVE');
|
||||
expect(row.main__amount).toBe('10.5000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { DateTime } from '../../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../../common/value-objects/decimal/decimal';
|
||||
import { DATA_FORMAT } from '../constants/data-format';
|
||||
import type { DataFormat } from '../constants/data-format';
|
||||
import type { ReportColumnConfigEntity } from '../entities/report-config.entity';
|
||||
|
||||
export function formatReportCell(raw: unknown, format: DataFormat): unknown {
|
||||
if (raw === null || raw === undefined) {
|
||||
switch (format) {
|
||||
case DATA_FORMAT.NUMBER:
|
||||
case DATA_FORMAT.CURRENCY:
|
||||
case DATA_FORMAT.MINUS_CURRENCY:
|
||||
return Decimal.zero().toString();
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case DATA_FORMAT.TEXT:
|
||||
return String(raw);
|
||||
case DATA_FORMAT.TEXT_UPPERCASE:
|
||||
return String(raw).toUpperCase();
|
||||
case DATA_FORMAT.TEXT_LOWERCASE:
|
||||
return String(raw).toLowerCase();
|
||||
case DATA_FORMAT.NUMBER:
|
||||
return formatDecimalOrZero(raw);
|
||||
case DATA_FORMAT.CURRENCY:
|
||||
return formatDecimalOrZero(raw);
|
||||
case DATA_FORMAT.MINUS_CURRENCY:
|
||||
return negateDecimalString(formatDecimalOrZero(raw));
|
||||
case DATA_FORMAT.PERCENTAGE:
|
||||
return `${raw}%`;
|
||||
case DATA_FORMAT.BOOLEAN:
|
||||
return raw === true || raw === 1 || raw === '1' ? 'Yes' : 'No';
|
||||
case DATA_FORMAT.STATUS:
|
||||
return String(raw);
|
||||
case DATA_FORMAT.DATE_EPOCH:
|
||||
return formatEpoch(raw);
|
||||
case DATA_FORMAT.DATE_TIMESTAMP:
|
||||
return formatTimestamp(raw);
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReportRow(
|
||||
row: Record<string, unknown>,
|
||||
columnConfigs: ReportColumnConfigEntity[],
|
||||
): Record<string, unknown> {
|
||||
const formatted: Record<string, unknown> = { ...row };
|
||||
for (const col of columnConfigs) {
|
||||
if (col.column in formatted) {
|
||||
formatted[col.column] = formatReportCell(
|
||||
formatted[col.column],
|
||||
col.format,
|
||||
);
|
||||
}
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function formatDecimalOrZero(raw: unknown): string {
|
||||
try {
|
||||
if (raw === null || raw === undefined || raw === '') {
|
||||
return Decimal.zero().toString();
|
||||
}
|
||||
return Decimal.create(String(raw)).toString();
|
||||
} catch {
|
||||
return Decimal.zero().toString();
|
||||
}
|
||||
}
|
||||
|
||||
function negateDecimalString(value: string): string {
|
||||
try {
|
||||
const decimal = Decimal.create(value);
|
||||
if (decimal.isZero()) {
|
||||
return decimal.toString();
|
||||
}
|
||||
return Decimal.create(`-${value.replace(/^-/, '')}`).toString();
|
||||
} catch {
|
||||
return Decimal.zero().toString();
|
||||
}
|
||||
}
|
||||
|
||||
function formatEpoch(raw: unknown): string {
|
||||
const ms = Number(raw);
|
||||
if (!Number.isFinite(ms)) {
|
||||
return String(raw);
|
||||
}
|
||||
return DateTime.fromUnixMs(ms).format();
|
||||
}
|
||||
|
||||
function formatTimestamp(raw: unknown): string {
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return DateTime.create(raw).format();
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const ms = Number(raw);
|
||||
if (Number.isFinite(ms)) {
|
||||
return DateTime.fromUnixMs(ms).format();
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { parseGroupNamesQuery } from './report-query-params';
|
||||
|
||||
describe('parseGroupNamesQuery', () => {
|
||||
it('reads groupNames', () => {
|
||||
expect(parseGroupNamesQuery({ groupNames: 'sales_report' })).toEqual([
|
||||
'sales_report',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads axios bracket notation groupNames[]', () => {
|
||||
expect(parseGroupNamesQuery({ 'groupNames[]': 'sales_report' })).toEqual([
|
||||
'sales_report',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads array values', () => {
|
||||
expect(
|
||||
parseGroupNamesQuery({ groupNames: ['sales_report', 'logistics_report'] }),
|
||||
).toEqual(['sales_report', 'logistics_report']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export function parseGroupNamesQuery(
|
||||
query: Record<string, unknown>,
|
||||
): string[] {
|
||||
const raw =
|
||||
query.groupNames ??
|
||||
query['groupNames[]'] ??
|
||||
query['groupNames[0]'];
|
||||
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item) => String(item)).filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
return [String(raw)];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { salesOrderReport } from '../configs/sales-reports';
|
||||
import { FILTER_TYPE } from '../constants/filter-type';
|
||||
import { ReportQueryBuilder } from './report-query.builder';
|
||||
import { flattenSql } from './sql-test.helper';
|
||||
|
||||
describe('ReportQueryBuilder', () => {
|
||||
const baseQueryModel = {
|
||||
startRow: 0,
|
||||
endRow: 100,
|
||||
rowGroupCols: [],
|
||||
valueCols: [],
|
||||
pivotCols: [],
|
||||
pivotMode: false,
|
||||
groupKeys: [],
|
||||
filterModel: {},
|
||||
sortModel: [],
|
||||
};
|
||||
|
||||
it('builds ungrouped select with limit offset', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, baseQueryModel);
|
||||
const text = flattenSql(builder.getSqlData());
|
||||
expect(text).toContain('SELECT');
|
||||
expect(text).toContain('sales_orders main');
|
||||
expect(text).toContain('LIMIT');
|
||||
expect(text).toContain('OFFSET');
|
||||
});
|
||||
|
||||
it('builds count query without grouping', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, baseQueryModel);
|
||||
const text = flattenSql(builder.getSqlCount());
|
||||
expect(text).toContain('COUNT(main.id)');
|
||||
});
|
||||
|
||||
it('applies text equals filter with bound parameter', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, {
|
||||
...baseQueryModel,
|
||||
filterModel: {
|
||||
main__status: {
|
||||
type: FILTER_TYPE.TEXT_EQUALS,
|
||||
filter: 'active',
|
||||
},
|
||||
},
|
||||
});
|
||||
const text = flattenSql(builder.getSqlData());
|
||||
expect(text).toContain('main.status');
|
||||
expect(text).toContain('=');
|
||||
});
|
||||
|
||||
it('rejects unknown filter columns', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, {
|
||||
...baseQueryModel,
|
||||
filterModel: {
|
||||
evil__column: {
|
||||
type: FILTER_TYPE.TEXT_EQUALS,
|
||||
filter: 'x',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => builder.getSqlData()).toThrow('Invalid filter column');
|
||||
});
|
||||
|
||||
it('builds grouped select with countChildGroup', () => {
|
||||
const builder = new ReportQueryBuilder(salesOrderReport, {
|
||||
...baseQueryModel,
|
||||
rowGroupCols: [
|
||||
{ id: 'br__name', displayName: 'Branch', field: 'br__name' },
|
||||
],
|
||||
groupKeys: [],
|
||||
});
|
||||
const text = flattenSql(builder.getSqlData());
|
||||
expect(text).toContain('GROUP BY');
|
||||
expect(text).toContain('countChildGroup');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { SQL, sql } from 'drizzle-orm';
|
||||
import { FILTER_TYPE } from '../constants/filter-type';
|
||||
import {
|
||||
COUNT_CHILD_GROUP_COLUMN,
|
||||
type FilterModelEntry,
|
||||
type QueryModelEntity,
|
||||
type ReportConfigEntity,
|
||||
} from '../entities/report-config.entity';
|
||||
|
||||
export class ReportQueryBuilder {
|
||||
constructor(
|
||||
private readonly config: ReportConfigEntity,
|
||||
private readonly queryModel: QueryModelEntity,
|
||||
) {}
|
||||
|
||||
getSqlData(): SQL {
|
||||
const pageSize = this.queryModel.endRow - this.queryModel.startRow;
|
||||
const selectSql = this.buildSelectSql();
|
||||
const fromSql = sql.raw(this.config.tableSchema);
|
||||
const whereSql = this.buildWhereSql();
|
||||
const groupBySql = this.buildGroupBySql();
|
||||
const orderBySql = this.buildOrderBySql();
|
||||
|
||||
const parts: SQL[] = [sql`SELECT ${selectSql} FROM ${fromSql}`];
|
||||
if (whereSql) {
|
||||
parts.push(sql` WHERE ${whereSql}`);
|
||||
}
|
||||
if (groupBySql) {
|
||||
parts.push(sql` ${groupBySql}`);
|
||||
}
|
||||
if (orderBySql) {
|
||||
parts.push(sql` ${orderBySql}`);
|
||||
}
|
||||
parts.push(sql` LIMIT ${pageSize + 1} OFFSET ${this.queryModel.startRow}`);
|
||||
return sql.join(parts, sql.raw(''));
|
||||
}
|
||||
|
||||
getSqlCount(): SQL {
|
||||
const fromSql = sql.raw(this.config.tableSchema);
|
||||
const whereSql = this.buildWhereSql();
|
||||
const alias = this.mainAlias();
|
||||
const isGrouping = this.isDoingGrouping();
|
||||
|
||||
if (!isGrouping && this.queryModel.rowGroupCols.length === 0) {
|
||||
const parts: SQL[] = [
|
||||
sql`SELECT COUNT(${sql.raw(`${alias}.id`)}) AS count FROM ${fromSql}`,
|
||||
];
|
||||
if (whereSql) {
|
||||
parts.push(sql` WHERE ${whereSql}`);
|
||||
}
|
||||
return sql.join(parts, sql.raw(''));
|
||||
}
|
||||
|
||||
const groupExpr = this.currentGroupExpression();
|
||||
const parts: SQL[] = [
|
||||
sql`SELECT COUNT(DISTINCT ${sql.raw(groupExpr)}) + COUNT(DISTINCT CASE WHEN ${sql.raw(groupExpr)} IS NULL THEN 1 END) AS count FROM ${fromSql}`,
|
||||
];
|
||||
if (whereSql) {
|
||||
parts.push(sql` WHERE ${whereSql}`);
|
||||
}
|
||||
return sql.join(parts, sql.raw(''));
|
||||
}
|
||||
|
||||
private mainAlias(): string {
|
||||
return this.config.mainTableAlias ?? 'main';
|
||||
}
|
||||
|
||||
private isDoingGrouping(): boolean {
|
||||
return (
|
||||
this.queryModel.rowGroupCols.length > this.queryModel.groupKeys.length
|
||||
);
|
||||
}
|
||||
|
||||
private currentGroupColumn(): string | undefined {
|
||||
const index = this.queryModel.groupKeys.length;
|
||||
const col = this.queryModel.rowGroupCols[index];
|
||||
return col?.field ?? col?.id;
|
||||
}
|
||||
|
||||
private currentGroupExpression(): string {
|
||||
const field = this.currentGroupColumn();
|
||||
if (!field) {
|
||||
return `${this.mainAlias()}.id`;
|
||||
}
|
||||
return this.resolveColumnExpression(field);
|
||||
}
|
||||
|
||||
private resolveColumnExpression(column: string): string {
|
||||
if (this.config.customQueryColumn) {
|
||||
const custom = this.config.customQueryColumn(column);
|
||||
if (custom) {
|
||||
return custom;
|
||||
}
|
||||
}
|
||||
const colConfig = this.config.columnConfigs.find(
|
||||
(c) => c.column === column,
|
||||
);
|
||||
if (colConfig) {
|
||||
return colConfig.query;
|
||||
}
|
||||
return column.replace(/__/g, '.');
|
||||
}
|
||||
|
||||
private buildSelectSql(): SQL {
|
||||
if (this.isDoingGrouping()) {
|
||||
return this.buildGroupedSelectSql();
|
||||
}
|
||||
const columns = this.config.columnConfigs.map(
|
||||
(col) => sql`${sql.raw(col.query)} AS ${sql.raw(col.column)}`,
|
||||
);
|
||||
return sql.join(columns, sql.raw(', '));
|
||||
}
|
||||
|
||||
private buildGroupedSelectSql(): SQL {
|
||||
const groupField = this.currentGroupColumn();
|
||||
if (!groupField) {
|
||||
throw new BadRequestException('Invalid group configuration');
|
||||
}
|
||||
const groupExpr = this.resolveColumnExpression(groupField);
|
||||
const parts: SQL[] = [sql`${sql.raw(groupExpr)} AS ${sql.raw(groupField)}`];
|
||||
|
||||
const nextGroupIndex = this.queryModel.groupKeys.length + 1;
|
||||
const nextGroup = this.queryModel.rowGroupCols[nextGroupIndex];
|
||||
const alias = this.mainAlias();
|
||||
if (nextGroup) {
|
||||
const nextExpr = this.resolveColumnExpression(
|
||||
nextGroup.field ?? nextGroup.id,
|
||||
);
|
||||
parts.push(
|
||||
sql`COUNT(DISTINCT ${sql.raw(nextExpr)}) AS ${sql.raw(COUNT_CHILD_GROUP_COLUMN)}`,
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
sql`COUNT(${sql.raw(`${alias}.id`)}) AS ${sql.raw(COUNT_CHILD_GROUP_COLUMN)}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const valueCol of this.queryModel.valueCols) {
|
||||
const expr = this.resolveColumnExpression(valueCol.field);
|
||||
const agg = valueCol.aggFunc.toUpperCase();
|
||||
parts.push(
|
||||
sql`${sql.raw(agg)}(${sql.raw(expr)}) AS ${sql.raw(valueCol.field)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return sql.join(parts, sql.raw(', '));
|
||||
}
|
||||
|
||||
private buildWhereSql(): SQL | undefined {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
for (const cond of this.config.whereDefaultConditions ?? []) {
|
||||
conditions.push(sql.raw(cond));
|
||||
}
|
||||
|
||||
if (this.config.whereCondition) {
|
||||
for (const cond of this.config.whereCondition(
|
||||
this.queryModel.filterModel,
|
||||
)) {
|
||||
conditions.push(sql.raw(cond));
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.queryModel.groupKeys.length; i++) {
|
||||
const groupCol = this.queryModel.rowGroupCols[i];
|
||||
if (!groupCol) {
|
||||
continue;
|
||||
}
|
||||
const field = groupCol.field ?? groupCol.id;
|
||||
const expr = this.resolveColumnExpression(field);
|
||||
const key = this.queryModel.groupKeys[i];
|
||||
if (key === null || key === undefined || key === '') {
|
||||
conditions.push(sql`${sql.raw(expr)} IS NULL`);
|
||||
} else {
|
||||
conditions.push(sql`${sql.raw(expr)} = ${String(key)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ignoreKeys = new Set(this.config.ignoreFilterKeys ?? []);
|
||||
for (const [key, entry] of Object.entries(this.queryModel.filterModel)) {
|
||||
if (ignoreKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const filterConfig = this.config.filterConfigs?.find(
|
||||
(f) => f.filterColumn === key,
|
||||
);
|
||||
if (filterConfig?.hideField) {
|
||||
continue;
|
||||
}
|
||||
if (!this.isAllowedFilterColumn(key)) {
|
||||
throw new BadRequestException('Invalid filter column');
|
||||
}
|
||||
const filterSql = this.createFilterSql(key, entry);
|
||||
if (filterSql) {
|
||||
conditions.push(filterSql);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return sql.join(conditions, sql` AND `);
|
||||
}
|
||||
|
||||
private isAllowedFilterColumn(column: string): boolean {
|
||||
if (this.config.columnConfigs.some((c) => c.column === column)) {
|
||||
return true;
|
||||
}
|
||||
if (this.config.customQueryColumn?.(column)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private createFilterSql(
|
||||
column: string,
|
||||
entry: FilterModelEntry,
|
||||
): SQL | undefined {
|
||||
const expr = this.resolveColumnExpression(column);
|
||||
const rawExpr = sql.raw(expr);
|
||||
const filter = entry.filter;
|
||||
|
||||
switch (entry.type) {
|
||||
case FILTER_TYPE.TEXT_EQUALS:
|
||||
return sql`${rawExpr} = ${String(filter)}`;
|
||||
case FILTER_TYPE.TEXT_NOT_EQUAL:
|
||||
return sql`${rawExpr} <> ${String(filter)}`;
|
||||
case FILTER_TYPE.TEXT_CONTAINS:
|
||||
return sql`${rawExpr} ILIKE ${`%${String(filter)}%`}`;
|
||||
case FILTER_TYPE.TEXT_NOT_CONTAINS:
|
||||
return sql`${rawExpr} NOT ILIKE ${`%${String(filter)}%`}`;
|
||||
case FILTER_TYPE.TEXT_MULTIPLE_CONTAINS:
|
||||
case FILTER_TYPE.TEXT_IN_MEMBER_TEXT: {
|
||||
const values = Array.isArray(filter) ? filter : [filter];
|
||||
const strings = values.map((v) => String(v));
|
||||
if (strings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const patterns = strings.map((s) => sql`${`%${s}%`}`);
|
||||
return sql`${rawExpr} ILIKE ANY(ARRAY[${sql.join(patterns, sql`, `)}])`;
|
||||
}
|
||||
case FILTER_TYPE.NUMBER_EQUALS:
|
||||
return sql`${rawExpr} = ${Number(filter)}`;
|
||||
case FILTER_TYPE.NUMBER_NOT_EQUAL:
|
||||
return sql`${rawExpr} <> ${Number(filter)}`;
|
||||
case FILTER_TYPE.NUMBER_GREATER_THAN:
|
||||
return sql`${rawExpr} > ${Number(filter)}`;
|
||||
case FILTER_TYPE.NUMBER_LESS_THAN:
|
||||
return sql`${rawExpr} < ${Number(filter)}`;
|
||||
case FILTER_TYPE.NUMBER_IN_RANGE: {
|
||||
const range = filter as { from?: number; to?: number };
|
||||
if (range.from !== undefined && range.to !== undefined) {
|
||||
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
|
||||
}
|
||||
if (range.from !== undefined) {
|
||||
return sql`${rawExpr} >= ${range.from}`;
|
||||
}
|
||||
if (range.to !== undefined) {
|
||||
return sql`${rawExpr} <= ${range.to}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH: {
|
||||
const range = filter as { from?: number; to?: number };
|
||||
if (range.from !== undefined && range.to !== undefined) {
|
||||
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
|
||||
}
|
||||
if (range.from !== undefined) {
|
||||
return sql`${rawExpr} >= ${range.from}`;
|
||||
}
|
||||
if (range.to !== undefined) {
|
||||
return sql`${rawExpr} <= ${range.to}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case FILTER_TYPE.TEXT_IN_DATE_RANGE_TIMESTAMP: {
|
||||
const range = filter as { from?: string; to?: string };
|
||||
if (range.from !== undefined && range.to !== undefined) {
|
||||
return sql`${rawExpr} BETWEEN ${range.from} AND ${range.to}`;
|
||||
}
|
||||
if (range.from !== undefined) {
|
||||
return sql`${rawExpr} >= ${range.from}`;
|
||||
}
|
||||
if (range.to !== undefined) {
|
||||
return sql`${rawExpr} <= ${range.to}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private buildGroupBySql(): SQL | undefined {
|
||||
if (!this.isDoingGrouping()) {
|
||||
return undefined;
|
||||
}
|
||||
const groupField = this.currentGroupColumn();
|
||||
if (!groupField) {
|
||||
return undefined;
|
||||
}
|
||||
const expr = this.resolveColumnExpression(groupField);
|
||||
return sql`GROUP BY ${sql.raw(expr)}`;
|
||||
}
|
||||
|
||||
private buildOrderBySql(): SQL | undefined {
|
||||
const alias = this.mainAlias();
|
||||
const orders: SQL[] = [];
|
||||
|
||||
if (this.isDoingGrouping()) {
|
||||
const groupField = this.currentGroupColumn();
|
||||
if (groupField) {
|
||||
const expr = this.resolveColumnExpression(groupField);
|
||||
orders.push(sql`${sql.raw(expr)} ASC`);
|
||||
}
|
||||
} else {
|
||||
const sortModel = this.queryModel.sortModel;
|
||||
if (sortModel.length > 0) {
|
||||
for (const sort of sortModel) {
|
||||
const expr = this.resolveColumnExpression(sort.colId);
|
||||
const direction = sort.sort.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
orders.push(sql`${sql.raw(expr)} ${sql.raw(direction)}`);
|
||||
}
|
||||
} else {
|
||||
const defaultOrder = this.config.defaultOrderBy ?? [
|
||||
`${alias}.created_at DESC`,
|
||||
];
|
||||
for (const clause of defaultOrder) {
|
||||
orders.push(sql.raw(clause));
|
||||
}
|
||||
}
|
||||
const lowLevel = this.config.lowLevelOrderBy ?? [`${alias}.id DESC`];
|
||||
for (const clause of lowLevel) {
|
||||
orders.push(sql.raw(clause));
|
||||
}
|
||||
}
|
||||
|
||||
if (orders.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return sql`ORDER BY ${sql.join(orders, sql.raw(', '))}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
|
||||
export function flattenSql(sqlQuery: SQL): string {
|
||||
const parts: string[] = [];
|
||||
for (const chunk of sqlQuery.queryChunks) {
|
||||
if (typeof chunk === 'string') {
|
||||
parts.push(chunk);
|
||||
continue;
|
||||
}
|
||||
if (chunk && typeof chunk === 'object' && 'queryChunks' in chunk) {
|
||||
parts.push(flattenSql(chunk as SQL));
|
||||
continue;
|
||||
}
|
||||
if (chunk && typeof chunk === 'object' && 'value' in chunk) {
|
||||
parts.push(String((chunk as { value: unknown }).value));
|
||||
}
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
Reference in New Issue
Block a user