summaryrefslogtreecommitdiff
path: root/components/client-table-v2/adapter
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-08 15:58:20 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-08 15:58:20 +0900
commit137dc8abffcac7721890f320f183ab13eb30b790 (patch)
tree78a2362cfadbfb1297ce0f86608256dc932e95cc /components/client-table-v2/adapter
parentad29c8d9dc5ce3f57d1e994e84603edcdb961c12 (diff)
parentd853ddc380bc03d968872e9ce53d7ea13a5304f8 (diff)
Merge branch 'table-v2' into dujinkim
Diffstat (limited to 'components/client-table-v2/adapter')
-rw-r--r--components/client-table-v2/adapter/create-table-service.ts101
-rw-r--r--components/client-table-v2/adapter/drizzle-table-adapter.ts173
2 files changed, 274 insertions, 0 deletions
diff --git a/components/client-table-v2/adapter/create-table-service.ts b/components/client-table-v2/adapter/create-table-service.ts
new file mode 100644
index 00000000..41c38906
--- /dev/null
+++ b/components/client-table-v2/adapter/create-table-service.ts
@@ -0,0 +1,101 @@
+import { PgTable } from "drizzle-orm/pg-core";
+import { DrizzleTableAdapter, DrizzleTableState } from "./drizzle-table-adapter";
+import { ColumnDef } from "@tanstack/react-table";
+import { SQL, and, count } from "drizzle-orm";
+
+// Define a minimal DB interface that we need
+// Adjust this to match your actual db instance type
+interface DbInstance {
+ select: (args?: any) => any;
+}
+
+export interface CreateTableServiceConfig<TData> {
+ /**
+ * Drizzle Database Instance
+ */
+ db: DbInstance;
+
+ /**
+ * Drizzle Table Schema (e.g. users, orders)
+ */
+ schema: PgTable; // Using PgTable as base, works for most Drizzle tables
+
+ /**
+ * React Table Columns Definition
+ * Used to map accessorKeys to DB columns
+ */
+ columns: ColumnDef<TData, any>[];
+
+ /**
+ * Optional: Custom WHERE clause to always apply (e.g. deleted_at IS NULL)
+ */
+ defaultWhere?: SQL;
+
+ /**
+ * Optional: Custom query modifier
+ * Allows joining other tables or selecting specific fields
+ */
+ customQuery?: (queryBuilder: any) => any;
+}
+
+/**
+ * Factory function to create a standardized server action for a table.
+ *
+ * @example
+ * export const getUsers = createTableService({
+ * db,
+ * schema: users,
+ * columns: userColumns
+ * });
+ */
+export function createTableService<TData>(config: CreateTableServiceConfig<TData>) {
+ const { db, schema, columns, defaultWhere, customQuery } = config;
+
+ // Return the actual Server Action function
+ return async function getTableData(tableState: DrizzleTableState) {
+ const adapter = new DrizzleTableAdapter(schema, columns);
+ const { where, orderBy, limit, offset, groupBy } = adapter.getQueryParts(tableState);
+
+ // Merge defaultWhere with dynamic where
+ const finalWhere = defaultWhere
+ ? (where ? and(defaultWhere, where) : defaultWhere)
+ : where;
+
+ // 1. Build Data Query
+ let dataQuery = db.select()
+ .from(schema)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset);
+
+ if (groupBy && groupBy.length > 0) {
+ dataQuery = dataQuery.groupBy(...groupBy);
+ }
+
+ // Apply custom query modifications (joins, etc)
+ if (customQuery) {
+ dataQuery = customQuery(dataQuery);
+ }
+
+ // 2. Build Count Query
+ const countQuery = db.select({ count: count() })
+ .from(schema)
+ .where(finalWhere);
+
+ // Execute queries
+ // We use Promise.all to run them in parallel
+ const [data, countResult] = await Promise.all([
+ dataQuery,
+ countQuery
+ ]);
+
+ const totalRows = Number(countResult[0]?.count ?? 0);
+
+ return {
+ data: data as TData[],
+ totalRows,
+ pageCount: Math.ceil(totalRows / (tableState.pagination?.pageSize ?? 10))
+ };
+ };
+}
diff --git a/components/client-table-v2/adapter/drizzle-table-adapter.ts b/components/client-table-v2/adapter/drizzle-table-adapter.ts
new file mode 100644
index 00000000..05bf4c5f
--- /dev/null
+++ b/components/client-table-v2/adapter/drizzle-table-adapter.ts
@@ -0,0 +1,173 @@
+import {
+ ColumnFiltersState,
+ SortingState,
+ PaginationState,
+ GroupingState,
+ ColumnDef
+} from "@tanstack/react-table";
+import {
+ SQL,
+ and,
+ or,
+ eq,
+ ilike,
+ gt,
+ lt,
+ gte,
+ lte,
+ inArray,
+ asc,
+ desc,
+ getTableColumns,
+} from "drizzle-orm";
+import { PgTable, PgView, PgColumn } from "drizzle-orm/pg-core";
+
+// Helper to detect if value is empty or undefined
+const isEmpty = (value: any) => value === undefined || value === null || value === "";
+
+export interface DrizzleTableState {
+ sorting?: SortingState;
+ columnFilters?: ColumnFiltersState;
+ globalFilter?: string;
+ pagination?: PaginationState;
+ grouping?: GroupingState;
+}
+
+export class DrizzleTableAdapter<TData> {
+ private columnMap: Map<string, PgColumn>;
+
+ constructor(
+ private table: PgTable | PgView,
+ private columns: ColumnDef<TData, any>[]
+ ) {
+ // Create a map of accessorKey -> Drizzle Column for fast lookup
+ this.columnMap = new Map();
+ // @ts-ignore - getTableColumns works on views in newer drizzle versions or we can cast
+ const drizzleColumns = getTableColumns(table as any);
+
+ columns.forEach(col => {
+ // We currently only support accessorKey which maps directly to a DB column
+ if ('accessorKey' in col && typeof col.accessorKey === 'string') {
+ const dbCol = drizzleColumns[col.accessorKey];
+ if (dbCol) {
+ this.columnMap.set(col.accessorKey, dbCol);
+ }
+ }
+ });
+ }
+
+ private getColumn(columnId: string): PgColumn | undefined {
+ return this.columnMap.get(columnId);
+ }
+
+ /**
+ * Build the WHERE clause based on column filters and global filter
+ */
+ getWhere(columnFilters?: ColumnFiltersState, globalFilter?: string): SQL | undefined {
+ const conditions: SQL[] = [];
+
+ // 1. Column Filters
+ if (columnFilters) {
+ for (const filter of columnFilters) {
+ const column = this.getColumn(filter.id);
+ if (!column) continue;
+
+ const value = filter.value;
+ if (isEmpty(value)) continue;
+
+ // Handle Array (range or multiple select)
+ if (Array.isArray(value)) {
+ // Range filter (e.g. [min, max])
+ if (value.length === 2 && (typeof value[0] === 'number' || typeof value[0] === 'string')) {
+ const [min, max] = value;
+ if (!isEmpty(min) && !isEmpty(max)) {
+ conditions.push(and(gte(column, min), lte(column, max))!);
+ } else if (!isEmpty(min)) {
+ conditions.push(gte(column, min)!);
+ } else if (!isEmpty(max)) {
+ conditions.push(lte(column, max)!);
+ }
+ }
+ // Multi-select (IN)
+ else if (value.length > 0) {
+ conditions.push(inArray(column, value)!);
+ }
+ }
+ // Boolean
+ else if (typeof value === 'boolean') {
+ conditions.push(eq(column, value)!);
+ }
+ // Number
+ else if (typeof value === 'number') {
+ conditions.push(eq(column, value)!);
+ }
+ // String (Search)
+ else if (typeof value === 'string') {
+ conditions.push(ilike(column, `%${value}%`)!);
+ }
+ }
+ }
+
+ // 2. Global Filter
+ if (globalFilter) {
+ const searchConditions: SQL[] = [];
+ this.columnMap.forEach((column) => {
+ // Implicitly supports only text-compatible columns for ilike
+ // Drizzle might throw if type mismatch, so user should be aware
+ searchConditions.push(ilike(column, `%${globalFilter}%`));
+ });
+
+ if (searchConditions.length > 0) {
+ conditions.push(or(...searchConditions)!);
+ }
+ }
+
+ return conditions.length > 0 ? and(...conditions) : undefined;
+ }
+
+ /**
+ * Build the ORDER BY clause
+ */
+ getOrderBy(sorting?: SortingState): SQL[] {
+ if (!sorting || !sorting.length) return [];
+
+ return sorting.map((sort) => {
+ const column = this.getColumn(sort.id);
+ if (!column) return null;
+ return sort.desc ? desc(column) : asc(column);
+ }).filter(Boolean) as SQL[];
+ }
+
+ /**
+ * Build the GROUP BY clause
+ */
+ getGroupBy(grouping?: GroupingState): SQL[] {
+ if (!grouping || !grouping.length) return [];
+
+ return grouping.map(g => this.getColumn(g)).filter(Boolean) as SQL[];
+ }
+
+ /**
+ * Get Limit and Offset
+ */
+ getPagination(pagination?: PaginationState) {
+ if (!pagination) return { limit: 10, offset: 0 };
+ return {
+ limit: pagination.pageSize,
+ offset: pagination.pageIndex * pagination.pageSize,
+ };
+ }
+
+ /**
+ * Helper to apply all state to a query builder.
+ * Returns the modifier objects that can be passed to drizzle query builder.
+ */
+ getQueryParts(state: DrizzleTableState) {
+ return {
+ where: this.getWhere(state.columnFilters, state.globalFilter),
+ orderBy: this.getOrderBy(state.sorting),
+ groupBy: this.getGroupBy(state.grouping),
+ ...this.getPagination(state.pagination)
+ };
+ }
+}