UNPKG

chanfana

Version:

OpenAPI 3 and 3.1 schema generator and validator for Hono, itty-router and more!

143 lines (124 loc) 5.3 kB
import { ApiException } from "../../exceptions"; import { ListEndpoint } from "../list"; import type { ListFilters, Logger } from "../types"; import { buildOrderByClause, buildWhereClause, getD1Binding, validateColumnName, validateOrderByColumn, validateOrderDirection, validateTableName, } from "./base"; /** * D1-specific ListEndpoint implementation. * Provides automatic SELECT with pagination, filtering, and ordering. * Includes SQL injection prevention for all query components. */ export class D1ListEndpoint<HandleArgs extends Array<object> = Array<object>> extends ListEndpoint<HandleArgs> { /** Name of the D1 database binding in the worker environment. Defaults to "DB" */ dbName = "DB"; /** Optional logger for debugging and error tracking */ logger?: Logger; /** Maximum number of results per page. Override to change the limit. */ maxPerPage = 100; /** * Gets the D1 database binding from the worker environment. * @returns D1Database instance * @throws ApiException if binding is not defined or is not a D1 binding */ getDBBinding(): D1Database { return getD1Binding((args) => this.params.router.getBindings(args), this.args, this.dbName); } /** * Gets the list of valid column names from the model schema. * @returns Array of valid column names */ protected getValidColumns(): string[] { return Object.keys(this.meta.model.schema.shape); } /** * Lists records with pagination, filtering, and ordering. * @param filters - Filter conditions and pagination options * @returns Object containing results and pagination info */ async list(filters: { filters?: Array<{ field: string; operator: string; value: unknown }>; options?: ListFilters["options"]; }) { const tableName = validateTableName(this.meta.model.tableName); const validColumns = this.getValidColumns(); // Safe pagination defaults — use custom field names for option keys const perPage = Math.min(Math.max(1, Number(filters?.options?.[this.perPageFieldName]) || 20), this.maxPerPage); const page = Math.max(1, Number(filters?.options?.[this.pageFieldName]) || 1); const limit = perPage; const offset = Math.max(0, (page - 1) * perPage); // Build filter conditions const conditions: string[] = []; const conditionsParams: unknown[] = []; for (const f of filters?.filters || []) { // Handle search field specially if (this.searchFields && this.searchFields.length > 0 && f.field === this.searchFieldName) { // Validate all search fields const validatedSearchFields = this.searchFields.map((col) => validateColumnName(col, validColumns)); const searchCondition = validatedSearchFields .map((col) => `UPPER(${col}) LIKE UPPER(?${conditionsParams.length + 1}) ESCAPE '\\'`) .join(" OR "); conditions.push(`(${searchCondition})`); // Escape the ESCAPE character itself first, then LIKE wildcards const escaped = String(f.value).replace(/\\/g, "\\\\").replace(/[%_]/g, "\\$&"); conditionsParams.push(`%${escaped}%`); } else if (f.operator === "EQ") { // Validate filter column const validatedColumn = validateColumnName(f.field, validColumns); conditions.push(`${validatedColumn} = ?${conditionsParams.length + 1}`); conditionsParams.push(f.value); } else { throw new ApiException(`Operator "${f.operator}" is not implemented`); } } const whereClause = buildWhereClause(conditions); // Build safe ORDER BY clause const orderByFields = Array.isArray(this.orderByFields) ? this.orderByFields : []; const primaryKey = this.meta?.model?.primaryKeys?.[0] || "id"; // Determine the fallback column (defaultOrderBy or first primary key) const fallbackColumn = typeof this.defaultOrderBy === "string" && this.defaultOrderBy !== "undefined" ? this.defaultOrderBy : primaryKey; // Get validated order column — use custom field names for option keys const orderColumn = validateOrderByColumn( filters?.options?.[this.orderByFieldName] as string | undefined, orderByFields, fallbackColumn, ); const orderDirection = validateOrderDirection( filters?.options?.[this.orderByDirectionFieldName] as string | undefined, ); const orderByClause = buildOrderByClause(orderColumn, orderDirection); // Build final SQL const dataSql = `SELECT * FROM ${tableName} ${whereClause} ${orderByClause} LIMIT ${limit} OFFSET ${offset}`; const countSql = `SELECT COUNT(*) as total FROM ${tableName} ${whereClause}`; if (this.logger) { this.logger.debug?.(`[D1ListEndpoint] SQL: ${dataSql}`, conditionsParams); } // Execute queries const [results, totalCount] = await Promise.all([ this.getDBBinding() .prepare(dataSql) .bind(...conditionsParams) .all(), this.getDBBinding() .prepare(countSql) .bind(...conditionsParams) .all(), ]); return { result: results.results, result_info: { count: results.results.length, page, per_page: perPage, total_count: (totalCount.results[0] as { total: number })?.total ?? 0, }, }; } }