Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions libs/database/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,35 @@
import {
eq,
gt,
gte,
ilike,
inArray,
isNotNull,
isNull,
lt,
lte,
ne,
not,
type SQL,
} from 'drizzle-orm';

import type { FilterOperator } from './interfaces';
import type { PgColumn } from 'drizzle-orm/pg-core';

export const FILTER_MAP: Record<FilterOperator, (col: PgColumn, val?: unknown) => SQL> = {
eq: (col, val) => eq(col, val),
ne: (col, val) => ne(col, val),
gt: (col, val) => gt(col, val),
gte: (col, val) => gte(col, val),
lt: (col, val) => lt(col, val),
lte: (col, val) => lte(col, val),
like: (col, val) => ilike(col, `%${val}%`),
ilike: (col, val) => ilike(col, `%${val}%`),
in: (col, val) => inArray(col, Array.isArray(val) ? val : []),
notIn: (col, val) => not(inArray(col, Array.isArray(val) ? val : [])),
isNull: (col) => isNull(col),
isNotNull: (col) => isNotNull(col),
};

export const DATABASE_SERVICE = 'DATABASE_SERVICE';
export const SQL_CLIENT = 'SQL_CLIENT';
3 changes: 2 additions & 1 deletion libs/database/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './database.module';
export { DATABASE_SERVICE } from './constants';
export type { DatabaseService } from './interfaces';
export type { DatabaseService, CursorResult, PaginatedResult } from './interfaces';
export { DatabaseHealthService } from './database-health.service';
export { paginateCursor, paginateOffset } from './pagination';
1 change: 1 addition & 0 deletions libs/database/src/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './module.interface';
export type * from './paginate.interface';
70 changes: 70 additions & 0 deletions libs/database/src/interfaces/paginate.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { PgColumn } from 'drizzle-orm/pg-core';

export type FilterOperator =
| 'eq'
| 'ne'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'like'
| 'ilike'
| 'in'
| 'notIn'
| 'isNull'
| 'isNotNull';

export interface FilterCondition<TColumn extends PgColumn = PgColumn> {
column: TColumn;
operator: FilterOperator;
value?: unknown;
}

export interface SearchConfig {
columns: PgColumn[];
value: string;
}

export interface SortConfig<TColumn extends PgColumn = PgColumn> {
column: TColumn;
order: 'asc' | 'desc';
}

export interface OffsetOptions {
page?: number;
limit?: number;
offset?: number;
sort?: SortConfig;
filters?: FilterCondition[];
search?: SearchConfig;
}

export interface CursorOptions {
column: PgColumn;
cursor?: string;
limit?: number;
sort?: SortConfig;
filters?: FilterCondition[];
search?: SearchConfig;
}

export interface PaginatedResult<T> {
items: T[];
meta: {
hasNextPage: boolean;
hasPrevPage: boolean;
total: number;
totalPages: number;
page: number;
limit: number;
};
}

export interface CursorResult<T> {
items: T[];
meta: {
next: string | null;
limit: number;
hasNext: boolean;
};
}
77 changes: 77 additions & 0 deletions libs/database/src/pagination/cursor.paginate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { lt, gt, and } from 'drizzle-orm';

import { applyOrder, buildConditions } from './utils';

import type { CursorOptions, CursorResult } from '../interfaces';
import type { PgSelect } from 'drizzle-orm/pg-core';

/**
* Выполняет курсорную пагинацию на основе уникального поля `id`.
*
* **Важно:** Эта реализация работает только с простым курсором — значением поля `id`
* последнего элемента предыдущей страницы. Составные курсоры и сортировка
* по не-unique полям (где возможны дубликаты) **не поддерживаются**.
*
* @template TRow - Тип строки результата (выводится из запроса)
*
* @param {PgSelect} query - Drizzle ORM динамический запрос (`.$dynamic()`)
* @param {CursorOptions} options - Параметры пагинации
* @param {PgColumn} options.column - Колонка для сортировки и курсора (должна быть уникальной, обычно `id`)
* @param {SortConfig} [options.sort] - Объект `{ column, order }` (по умолчанию `{ column: options.column, order: 'asc' }`)
* @param {number} [options.limit=25] - Количество записей на странице (максимум 50)
* @param {string} [options.cursor] - Значение `id` последнего элемента предыдущей страницы
* @param {FilterCondition[]} [options.filters] - Дополнительные условия фильтрации
* @param {{ columns: PgColumn[]; value: string }} [options.search] - Поисковый запрос
*
* @returns {Promise<CursorResult<TRow>>} Объект `{ items, meta: { next, hasNext, limit } }`
*
* @example
* ```ts
* const result = await paginateCursor(query, {
* column: schema.projects.id,
* sort: { column: schema.projects.id, order: 'asc' },
* limit: 20,
* cursor: query.cursor,
* });
* ```
*/
export async function paginateCursor<TRow>(
query: PgSelect,
options: CursorOptions,
): Promise<CursorResult<TRow>> {
const MAX_LIMIT = 50;
const DEFAULT_LIMIT = 25;
const limit = Math.min(Math.max(1, options.limit ?? DEFAULT_LIMIT), MAX_LIMIT) + 1;

const sort = options.sort ?? { column: options.column, order: 'asc' as const };
if (!sort.column) {
throw new Error('Sort column is required for cursor pagination');
}

const conditions = buildConditions(options);

if (options.cursor) {
conditions.push(
sort.order === 'desc'
? lt(sort.column, options.cursor)
: gt(sort.column, options.cursor),
);
}

const orderByClause = applyOrder(sort);
const filteredQuery = conditions.length > 0 ? query.where(and(...conditions)) : query;

const items = await filteredQuery.orderBy(orderByClause).limit(limit);
const hasNext = items.length === limit;

if (hasNext) {
items.pop();
}

const next = hasNext && items.length > 0 ? String(items[items.length - 1]?.['id']) : null;

return {
items: items as TRow[],
meta: { next, hasNext, limit: limit - 1 },
};
}
2 changes: 2 additions & 0 deletions libs/database/src/pagination/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { paginateCursor } from './cursor.paginate';
export { paginateOffset } from './offest.paginate';
40 changes: 40 additions & 0 deletions libs/database/src/pagination/offest.paginate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { and, count } from 'drizzle-orm';

import { applyOrder, buildConditions, withFallback } from './utils';

import type { PaginatedResult, DatabaseService, OffsetOptions } from '../interfaces';
import type { PgSelect } from 'drizzle-orm/pg-core';

export async function paginateOffset<TRow>(
db: DatabaseService,
query: PgSelect,
options: OffsetOptions = {},
): Promise<PaginatedResult<TRow>> {
const page = withFallback(options.page, 1);
const limit = Math.min(withFallback(options.limit, 20), 100);
const offset = withFallback(options.offset, (page - 1) * limit);

const conditions = buildConditions(options);
const orderBy = options.sort ? [applyOrder(options.sort)] : [];
const filtered = conditions.length > 0 ? query.where(and(...conditions)) : query;

const [data, [totalRow]] = await Promise.all([
(orderBy.length > 0 ? filtered.orderBy(...orderBy) : filtered).limit(limit).offset(offset),
db.select({ count: count() }).from(filtered.as('_count')),
]);

const total = Number(totalRow?.count ?? 0);
const totalPages = Math.ceil(total / limit);

return {
items: data as TRow[],
meta: {
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
total,
totalPages,
page,
limit,
},
};
}
50 changes: 50 additions & 0 deletions libs/database/src/pagination/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { asc, desc, type SQL } from 'drizzle-orm';

import { FILTER_MAP } from '../constants';

import type { FilterCondition, SortConfig } from '../interfaces';
import type { PgColumn } from 'drizzle-orm/pg-core';

export const applyFilter = ({ column, operator, value }: FilterCondition): SQL =>
(FILTER_MAP[operator] ?? FILTER_MAP.eq)(column, value);

export const applyOrder = ({ column, order }: SortConfig): SQL =>
order === 'desc' ? desc(column) : asc(column);

export const withFallback = <T>(value: T | undefined, fallback: T): T => value ?? fallback;

export const buildConditions = (options: {
filters?: FilterCondition[];
search?: { columns: PgColumn[]; value: string };
}): SQL[] => {
const conditions: SQL[] = [];
if (options.filters?.length) {
conditions.push(...options.filters.map(applyFilter));
}
// if (options.search?.value && options.search.columns?.length) {
// const searchConditions = options.search.columns.map((col) =>
// ilike(col, `%${options.search.value}%`),
// );
// conditions.push(
// searchConditions.length === 1 ? searchConditions[0] : or(...searchConditions),
// );
// }
return conditions;
};

export const getColumnName = (column: PgColumn | string): string =>
typeof column === 'string' ? column : column.name || 'id';

export const encode = (value: unknown): string => {
if (value === null) {
return '';
}
return Buffer.from(JSON.stringify(value)).toString('base64');
};

export const decode = (cursor: string): unknown => {
if (!cursor) {
return null;
}
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
};
15 changes: 3 additions & 12 deletions src/area/application/dtos/state.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { STATE_CATEGORIES, STATE_TYPES } from '@core/area/domain/entities';
import { createSortingSchema, PaginationBaseSchema, ActionResponseSchema } from '@shared/schemas';
import { createSortingSchema, CursorQuerySchema, ActionResponseSchema } from '@shared/schemas';
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod/v4';

Expand Down Expand Up @@ -147,16 +147,7 @@ export const QueryParamsSchema = z
category: z.string().optional().describe('Фильтр по категории'),
overdue: z.boolean().optional().default(false).describe('Только просроченные'),
})
.extend(PaginationBaseSchema.shape)
.extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape)
.transform((data) => {
if (data.page > 1 && data.offset === 0) {
return {
...data,
offset: (data.page - 1) * (data.limit || 20),
};
}
return data;
});
.extend(CursorQuerySchema.shape)
.extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape);

export class QueryParamsDto extends createZodDto(QueryParamsSchema) {}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class IdentitiyRepository implements IIdentityRepository {
private readonly db: DatabaseService<typeof schema>,
) {}

public readonly create = async (data: typeof schema.userIdentities.$inferInsert) => {
public create = async (data: typeof schema.userIdentities.$inferInsert) => {
const [result] = await this.db.insert(schema.userIdentities).values(data).returning();

if (!result) {
Expand All @@ -22,21 +22,21 @@ export class IdentitiyRepository implements IIdentityRepository {
return result;
};

public readonly delete = async (id: string) => {
public delete = async (id: string) => {
const result = await this.db
.delete(schema.userIdentities)
.where(eq(schema.userIdentities.id, id));

return result.count.valueOf() > 0;
};

public readonly findAllByUserId = async (userId: string) =>
public findAllByUserId = async (userId: string) =>
this.db
.select()
.from(schema.userIdentities)
.where(eq(schema.userIdentities.userId, userId));

public readonly findByProvider = async (
public findByProvider = async (
provider: 'google' | 'yandex' | 'github',
providerUserId: string,
) => {
Expand Down
4 changes: 2 additions & 2 deletions src/issue/application/dtos/issue.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from 'zod/v4';
import {
ActionResponseSchema,
createSortingSchema,
PaginationBaseSchema,
CursorQuerySchema,
} from '../../../shared/schemas';
import { ISSUE_TYPE_LIST, PRIORITY_LIST } from '../../domain/entities';

Expand Down Expand Up @@ -221,7 +221,7 @@ export const IssueFiltersQuerySchema = IssueQuerySchema.extend({
.optional()
.describe('Метки через запятую (AND — задача должна иметь все указанные)'),
})
.extend(PaginationBaseSchema.shape)
.extend(CursorQuerySchema.shape)
.extend(createSortingSchema(['position', 'createdAt', 'priority']).shape)
.describe('Query параметры для получения списка задач с фильтрацией');

Expand Down
4 changes: 2 additions & 2 deletions src/issue/application/issue.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import {
AssignIssueDto,
CreateIssueDto,
IssueQueryDto,
IssueFiltersQueryDto,
MoveIssueDto,
UpdateIssueDto,
} from './dtos';
Expand Down Expand Up @@ -37,7 +37,7 @@ export class IssueFacade {
public getOne = async (id: string, slug: string, userId: string) =>
this.getOneIssueQ.execute(id, slug, userId);

public getAll = async (query: IssueQueryDto, userId: string) =>
public getAll = async (query: IssueFiltersQueryDto, userId: string) =>
this.getAllIssueQ.execute(query, userId);

public update = async (
Expand Down
Loading
Loading