;
export interface IBaseRepositoryimport { SQLWrapper } from "drizzle-orm";
import { PgColumn, PgTable } from "drizzle-orm/pg-core";
import { z } from "zod";
import { FilterRuleGroupSchema, OrderDirection } from "./FIlterBuilder";
export type ID = number | string;
export type OrderDirection = "asc" | "desc";
export type FindOptionsQL = {
where?: SQLWrapper;
limit?: number;
offset?: number;
orderBy?: {
column: PgColumn;
direction: OrderDirection;
}[];
};
export const FindOptionsSchema = z
.object({
where: FilterRuleGroupSchema,
limit: z.number().default(10), //todo - use app configaration for default value
offset: z.number().default(0),
orderBy: z.array(
z.object({
column: z.string(),
direction: z.enum(OrderDirection),
})
),
})
.partial();
export type FindOptions = z.infer<typeof FindOptionsSchema>;
export interface IBaseRepository<TTable extends PgTable & { id: SQLWrapper }> {
//Query function
findAll(options?: FindOptionsQL): Promise<TTable["$inferSelect"][]>;
findById(id: ID): Promise<TTable["$inferSelect"] | null>;
findOne(where: SQLWrapper): Promise<TTable["$inferSelect"] | null>;
findAndCount(
where?: FindOptionsQL
): Promise<[TTable["$inferSelect"][], number]>;
count(where?: SQLWrapper): Promise<number>;
checkExist(where: SQLWrapper): Promise<boolean>;
checkExistById(id: ID): Promise<boolean>;
//Mutation function
create(data: TTable["$inferInsert"]): Promise<TTable["$inferSelect"]>;
createMany(data: TTable["$inferInsert"][]): Promise<TTable["$inferSelect"][]>;
//Update function
update(
id: ID,
data: Partial<TTable["$inferInsert"]>
): Promise<TTable["$inferSelect"] | null>;
updateMany(
data: Partial<TTable["$inferInsert"] & { id: ID }>[]
): Promise<TTable["$inferSelect"][] | null>;
//Delete function
delete(id: ID): Promise<void>;
deleteMany(ids: ID[]): Promise<void>;
getTable(): TTable;
}
import { asc, desc, eq, inArray, sql, SQLWrapper } from "drizzle-orm";
import { PgTable } from "drizzle-orm/pg-core";
import { IDatabaseClient } from "../db/IDatabasClient";
import { FindOptionsQL, IBaseRepository, ID } from "./IBaseRepository";
export abstract class BaseRepository<
TTable extends PgTable & { id: SQLWrapper }
> implements IBaseRepository<TTable>
{
constructor(
protected readonly db: IDatabaseClient,
protected readonly table: TTable
) {}
//Query function
async findAll(options?: FindOptionsQL): Promise<TTable["$inferSelect"][]> {
const result = await this.db.executeQuery("FindAll", async (db) => {
let query = db.select().from(this.table).$dynamic();
if (options?.where) {
query = query.where(options.where.getSQL());
}
if (options?.orderBy && options.orderBy.length > 0) {
const orderClause = options.orderBy.map((order) =>
order.direction === "asc" ? asc(order.column) : desc(order.column)
);
query = query.orderBy(...orderClause);
}
if (options?.limit) {
query = query.limit(options.limit);
}
if (options?.offset) {
query = query.offset(options.offset);
}
const records = await query.execute();
return records;
});
return result;
}
async findById(id: ID): Promise<TTable["$inferSelect"] | null> {
const result = await this.db.executeQuery("FindById", async (db) => {
const records = await db
.select()
.from(this.table)
.where(eq(this.table.id, id))
.execute();
return records[0] ?? null;
});
return result;
}
async findOne(where: SQLWrapper): Promise<TTable["$inferSelect"] | null> {
const result = await this.db.executeQuery("FindById", async (db) => {
const records = await db
.select()
.from(this.table)
.where(where.getSQL())
.execute();
return records[0] ?? null;
});
return result;
}
async findAndCount(
options?: FindOptionsQL
): Promise<[TTable["$inferSelect"][], number]> {
const result = await this.db.executeQuery("FindAndCount", async (db) => {
const recordsPromise = this.findAll(options);
const countPromise = this.count(options?.where);
const [records, count] = await Promise.all([
recordsPromise,
countPromise,
]);
return [records, count] as [TTable["$inferSelect"][], number];
});
return result;
}
async count(where?: SQLWrapper): Promise<number> {
const result = await this.db.executeQuery("Count", async (db) => {
let query = db
.select({ count: sql`count(*)` })
.from(this.table)
.$dynamic();
if (where) {
query = query.where(where.getSQL());
}
const records = await query.execute();
return Number(records[0]?.count);
});
return result;
}
async checkExist(where: SQLWrapper): Promise<boolean> {
const count = await this.count(where);
return count > 0;
}
async checkExistById(id: ID): Promise<boolean> {
return await this.checkExist(eq(this.table.id, id));
}
//Mutation function
async create(data: TTable["$inferInsert"]): Promise<TTable["$inferSelect"]> {
const result = await this.db.executeQuery("Create", async (db) => {
const records = await db.insert(this.table).values(data).returning();
return records[0];
});
return result as TTable["$inferSelect"];
}
async createMany(
data: TTable["$inferInsert"][]
): Promise<TTable["$inferSelect"][]> {
const result = await this.db.executeQuery("CreateMany", async (db) => {
const records = await db
.insert(this.table)
.values(data)
.returning()
.execute();
return records;
});
return result as TTable["$inferSelect"][];
}
//Update function
async update(
id: ID,
data: Partial<TTable["$inferInsert"]>
): Promise<TTable["$inferSelect"] | null> {
const result = await this.db.executeQuery("Update", async (db) => {
const records = await db
.update(this.table)
.set(data)
.where(eq(this.table.id, id))
.returning()
.execute();
return records;
});
if (!result) {
return null;
}
return (result as TTable["$inferSelect"][])[0];
}
async updateMany(
data: Partial<TTable["$inferInsert"] & { id: ID }>[]
): Promise<TTable["$inferSelect"][]> {
const result = await this.db.executeQuery("UpdateMany", async (db) => {
const records = await db
.update(this.table)
.set(data)
.where(eq(this.table.id, data[0].id))
.returning()
.execute();
return records;
});
return result as TTable["$inferSelect"][];
}
//Delete function
async delete(id: ID): Promise<void> {
await this.db.executeQuery("Delete", async (db) => {
await db.delete(this.table).where(eq(this.table.id, id)).execute();
});
}
async deleteMany(ids: ID[]): Promise<void> {
await this.db.executeQuery("DeleteMany", async (db) => {
await db.delete(this.table).where(inArray(this.table.id, ids));
});
}
getTable(): TTable {
return this.table;
}
}
import chalk from "chalk";
import { SQLWrapper } from "drizzle-orm";
import { PgColumn, PgTable } from "drizzle-orm/pg-core";
import { BaseRepository } from "./BaseRepository";
import { FilterBuilder } from "./FIlterBuilder";
import { FindOptions, ID, OrderDirection } from "./IBaseRepository";
import { IBaserService } from "./IBaserService";
export abstract class BaseService<
TTable extends PgTable & { id: SQLWrapper },
TRepository extends BaseRepository<TTable> = BaseRepository<TTable>
> implements IBaserService<TTable>
{
constructor(
private readonly repository: TRepository
) // protected readonly config: AppConfig; // todo - add config later
{}
async findAll(options?: FindOptions) {
const filter = options?.where
? FilterBuilder.build(options.where)
: undefined;
try {
return await this.repository.findAll({
where: filter,
limit: options?.limit ?? 10, // this.config.pagination.limit,
offset: options?.offset ?? 0,
orderBy: this.transformOrderBy(options?.orderBy),
});
} catch (err) {
this.handleError(err, "FindAll");
}
}
async findById(id: ID): Promise<TTable["$inferSelect"] | null> {
try {
const item = await this.repository.findById(id);
//Todo - Create Custom Error with status code
if (!item) {
throw new Error("Item not found");
}
return item;
} catch (error) {
this.handleError(error, "FindById");
}
}
async create(data: TTable["$inferInsert"]): Promise<TTable["$inferSelect"]> {
try {
const item = await this.repository.create(data);
return item;
} catch (error) {
this.handleError(error, "Create");
}
}
async update(
id: ID,
data: Partial<TTable["$inferInsert"]>
): Promise<TTable["$inferSelect"]> {
try {
const item = await this.repository.update(id, data);
if (!item) {
throw new Error("Item not found");
}
return item;
} catch (error) {
this.handleError(error, "Update");
}
}
async delete(id: ID): Promise<void> {
try {
await this.repository.delete(id);
} catch (error) {
this.handleError(error, "Delete");
}
}
async checkExists(id: ID): Promise<boolean> {
try {
const exists = await this.repository.checkExistById(id);
return exists;
} catch (error) {
this.handleError(error, "CheckExists");
}
}
// Private method: complete later
protected handleError(error: unknown, label: string): never {
console.error(chalk.red(`${label} Error`), error);
if (error instanceof Error) {
throw error;
}
throw new Error(
typeof error === "string" ? error : "An unexpected error occurred"
);
}
protected async catchError(callback: () => Promise<unknown>) {
try {
return await callback();
} catch (error) {
console.error(chalk.red("Error"), error);
if (error instanceof Error) {
throw error;
}
throw new Error(
typeof error === "string" ? error : "An unexpected error occurred"
);
}
}
protected transformOrderBy(orderBy: FindOptions['orderBy']) {
if (!orderBy) return undefined;
const table = this.repository.getTable();
return orderBy
.filter((order) => order.column in table)
.map((order) => ({
column: table[order.column as keyof typeof table] as PgColumn,
direction: order.direction as OrderDirection,
}));
}
}
import { and, not, or, sql, SQLWrapper } from "drizzle-orm";
import { z } from "zod";
export const Operators = {
// Comparison Operators
EQUALS: "=",
NOT_EQUALS: "!=",
GREATER: ">",
GREATER_EQUALS: ">=",
LESS: "<",
LESS_EQUALS: "<=",
// Text Operators
CONTAINS: "contains",
NOT_CONTAINS: "not_contains",
STARTS_WITH: "starts_with",
ENDS_WITH: "ends_with",
LIKE: "like",
ILIKE: "ilike",
// Null checks
IS_NULL: "is_null",
IS_NOT_NULL: "is_not_null",
// Lists
IN: "in",
NOT_IN: "not_in",
// Ranges
BETWEEN: "between",
NOT_BETWEEN: "not_between",
} as const;
export type Operator = (typeof Operators)[keyof typeof Operators];
export type Combinator = "and" | "or";
export const OrderDirection = ["asc" , "desc"] as const;
export type FilterRule = {
field: string;
operator: Operator; //todo - check operator which value which operator can have used
value?: unknown;
};
export const FilterRuleSchema = z.object({
field: z.string(),
operator: z.nativeEnum(Operators),
value: z.any(),
});
export type FilterRuleGroup = {
combinator: Combinator;
not?: boolean;
rules: Array<FilterRule | FilterRuleGroup>;
};
export const FilterRuleGroupSchema: z.ZodType<FilterRuleGroup> = z.lazy(() =>
z.object({
combinator: z.enum(["and", "or"]),
not: z.boolean().optional(),
rules: z.array(z.union([FilterRuleSchema, FilterRuleGroupSchema])),
})
);
export class FilterBuilder {
static build(filter: FilterRuleGroup): SQLWrapper {
return sql`1=1`;
}
static raw(query: SQLWrapper) {
return query.getSQL();
}
/**
* ===== Private Methods =====
*/
private static processRuleGroup(group: FilterRuleGroup): SQLWrapper {
const conditions = group.rules.map((rule) =>
this.isRuleGroup(rule)
? this.processRuleGroup(rule)
: this.processRule(rule)
);
if (conditions.length === 0) {
return sql`1=1`;
}
const result = (
group.combinator === "and" ? and(...conditions) : or(...conditions)
) as SQLWrapper;
return group.not ? not(result) : result;
}
private static isRuleGroup(
rule: FilterRule | FilterRuleGroup
): rule is FilterRuleGroup {
return "rules" in rule && rule.rules !== undefined;
}
private static processRule(rule: FilterRule): SQLWrapper {
const column = sql.identifier(rule.field);
switch (rule.operator) {
case Operators.EQUALS:
return sql`${column} = ${rule.value}`;
case Operators.NOT_EQUALS:
return sql`${column} != ${rule.value}`;
case Operators.GREATER:
return sql`${column} > ${rule.value}`;
case Operators.GREATER_EQUALS:
return sql`${column} >= ${rule.value}`;
case Operators.LESS:
return sql`${column} < ${rule.value}`;
case Operators.LESS_EQUALS:
return sql`${column} <= ${rule.value}`;
case Operators.CONTAINS:
return sql`${column} ILIKE %${rule.value}%`;
case Operators.NOT_CONTAINS:
return sql`${column} NOT ILIKE %${rule.value}%`;
case Operators.STARTS_WITH:
return sql`${column} ILIKE ${rule.value}%`;
case Operators.ENDS_WITH:
return sql`${column} ILIKE %${rule.value}`;
case Operators.LIKE:
return sql`${column} LIKE ${rule.value}`;
case Operators.ILIKE:
return sql`${column} ILIKE ${rule.value}`;
case Operators.IS_NULL:
return sql`${column} IS NULL`;
case Operators.IS_NOT_NULL:
return sql`${column} IS NOT NULL`;
case Operators.IN:
if (!Array.isArray(rule.value)) {
return sql`${column} = ANY(${rule.value})`;
}
return sql`${column} = ANY(ARRAY[${sql.join(rule.value)}])`;
case Operators.NOT_IN:
if (!Array.isArray(rule.value)) {
return sql`${column} != ANY(${rule.value})`;
}
return sql`${column} != ANY(ARRAY[${sql.join(rule.value)}])`;
case Operators.BETWEEN:
if (!Array.isArray(rule.value) || rule.value.length !== 2) {
throw new Error("Between operator requires two values");
}
return sql`${column} BETWEEN ${rule.value[0]} AND ${rule.value[1]}`;
case Operators.NOT_BETWEEN:
if (!Array.isArray(rule.value) || rule.value.length !== 2) {
throw new Error("Between operator requires two values");
}
return sql`${column} NOT BETWEEN ${rule.value[0]} AND ${rule.value[1]}`;
default:
throw new Error(`Unknown operator: ${rule.operator}`);
}
}
}