@kitledger/core 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/query.js ADDED
@@ -0,0 +1,217 @@
1
+ import { QuerySchema } from "@kitledger/query";
2
+ import { getTableName } from "drizzle-orm";
3
+ import knex from "knex";
4
+ import * as v from "valibot";
5
+ import { defaultLimit, defaultOffset, maxLimit, QueryResultSchema, } from "./db.js";
6
+ import { parseValibotIssues } from "./validation.js";
7
+ /**
8
+ * Maximum allowed nesting depth for filter groups to prevent overly complex queries.
9
+ */
10
+ const MAX_NESTING_DEPTH = 5;
11
+ /**
12
+ * Use valibot to validate and parse the incoming query parameters.
13
+ * @param params
14
+ * @returns
15
+ */
16
+ function validateQueryParams(params) {
17
+ const result = v.safeParse(QuerySchema, params);
18
+ if (!result.success) {
19
+ return { success: false, errors: parseValibotIssues(result.issues) };
20
+ }
21
+ return { success: true, data: result.output };
22
+ }
23
+ /**
24
+ * Prepares and executes a query against the specified table using the provided parameters.
25
+ * @param table
26
+ * @param params
27
+ * @returns
28
+ */
29
+ export async function executeQuery(db, table, params) {
30
+ const validationResult = validateQueryParams(params);
31
+ const parsedParams = validationResult.success ? validationResult.data : null;
32
+ if (!validationResult.success || !parsedParams) {
33
+ console.error("Validation errors", validationResult.errors);
34
+ return {
35
+ data: [],
36
+ count: 0,
37
+ offset: 0,
38
+ limit: 0,
39
+ errors: validationResult.errors?.map((e) => ({ field: e.path || undefined, message: e.message })),
40
+ };
41
+ }
42
+ try {
43
+ const knexBuilder = knex({ client: "pg" });
44
+ const limit = Math.min(parsedParams.limit ?? defaultLimit, maxLimit);
45
+ const offset = parsedParams.offset ?? defaultOffset;
46
+ const { sql, bindings } = buildQuery(knexBuilder, getTableName(table), params, limit, offset)
47
+ .toSQL()
48
+ .toNative();
49
+ console.log("Executing query:", sql, bindings);
50
+ const queryResult = await db.$client.unsafe(sql, bindings);
51
+ console.log("Query result:", queryResult);
52
+ const parsedQueryResult = v.safeParse(QueryResultSchema, queryResult);
53
+ if (!parsedQueryResult.success) {
54
+ console.error("Failed to parse query result", parsedQueryResult.issues);
55
+ throw new Error("Failed to parse query result");
56
+ }
57
+ return {
58
+ data: parsedQueryResult.output,
59
+ count: parsedQueryResult.output.length ?? 0,
60
+ offset: offset,
61
+ limit: limit,
62
+ };
63
+ }
64
+ catch (error) {
65
+ return {
66
+ data: [],
67
+ count: 0,
68
+ offset: 0,
69
+ limit: 0,
70
+ errors: [{ message: error instanceof Error ? error.message : "Query execution error" }],
71
+ };
72
+ }
73
+ }
74
+ /**
75
+ * Recursively applies filters from a ConditionGroup to a Knex query builder.
76
+ * @param queryBuilder
77
+ * @param filterGroup
78
+ * @param depth
79
+ */
80
+ function applyFilters(queryBuilder, filterGroup, depth) {
81
+ if (depth > MAX_NESTING_DEPTH) {
82
+ throw new Error(`Query nesting depth exceeds the maximum of ${MAX_NESTING_DEPTH}.`);
83
+ }
84
+ // Use a nested 'where' to group conditions with parentheses, e.g., WHERE ( ... )
85
+ queryBuilder.where(function () {
86
+ for (const filter of filterGroup.conditions) {
87
+ // Determine the chaining method (.where or .orWhere)
88
+ const connector = filterGroup.connector;
89
+ const method = filterGroup.connector === "or" ? "orWhere" : "where";
90
+ // If the filter is another group, recurse
91
+ if ("connector" in filter) {
92
+ this[method](function () {
93
+ // Pass the nested group directly and increment the depth
94
+ applyFilters(this, filter, depth + 1);
95
+ });
96
+ continue;
97
+ }
98
+ // Apply the specific filter condition
99
+ const { column, operator, value } = filter;
100
+ switch (operator) {
101
+ case "in": {
102
+ const caseMethod = connector === "or" ? "orWhereIn" : "whereIn";
103
+ this[caseMethod](column, value);
104
+ break;
105
+ }
106
+ case "not_in": {
107
+ const caseMethod = connector === "or" ? "orWhereNotIn" : "whereNotIn";
108
+ this[caseMethod](column, value);
109
+ break;
110
+ }
111
+ case "empty": {
112
+ const caseMethod = connector === "or" ? "orWhereNull" : "whereNull";
113
+ this[caseMethod](column);
114
+ break;
115
+ }
116
+ case "not_empty": {
117
+ const caseMethod = connector === "or" ? "orWhereNotNull" : "whereNotNull";
118
+ this[caseMethod](column);
119
+ break;
120
+ }
121
+ // Handles =, !=, >, <, etc.
122
+ default: {
123
+ this[method](column, operator, value);
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ });
129
+ }
130
+ /**
131
+ * Builds a Knex query object from a QueryOptions configuration.
132
+ * @param kx - The Knex instance.
133
+ * @param tableName - The name of the table to query.
134
+ * @param options - The QueryOptions object.
135
+ * @returns A Knex QueryBuilder instance.
136
+ */
137
+ export function buildQuery(kx, tableName, options, limit, offset) {
138
+ let query;
139
+ let fromClause = tableName;
140
+ // 1. Process Recursive CTE (now with hardcoded conventions)
141
+ if (options.recursive) {
142
+ // Hardcoded conventions for simplicity
143
+ const cteName = "hierarchy";
144
+ const parentKey = "id";
145
+ const childKey = "parent_id";
146
+ fromClause = cteName; // The rest of the query will select FROM the CTE result.
147
+ const { direction, startWith } = options.recursive;
148
+ // Build the "anchor" query that finds the starting records.
149
+ const anchorBuilder = kx.from(tableName).where((qb) => applyFilters(qb, startWith, 1));
150
+ const { sql: anchorSql, bindings: anchorBindings } = anchorBuilder.toSQL().toNative();
151
+ // Determine the join direction based on 'ancestors' or 'descendants'
152
+ const [joinFrom, joinTo] = direction === "ancestors"
153
+ ? [`t."${parentKey}"`, `h."${childKey}"`] // To find a parent, match table's PK to hierarchy's FK
154
+ : [`t."${childKey}"`, `h."${parentKey}"`]; // To find children, match table's FK to hierarchy's PK
155
+ const cteBodySql = `
156
+ (${anchorSql})
157
+ UNION ALL
158
+ SELECT t.*
159
+ FROM "${tableName}" AS t
160
+ JOIN "${cteName}" AS h ON ${joinFrom} = ${joinTo}
161
+ `;
162
+ query = kx.withRecursive(cteName, kx.raw(cteBodySql, anchorBindings)).from(fromClause);
163
+ }
164
+ else {
165
+ query = kx(fromClause);
166
+ }
167
+ // 2. Process Joins
168
+ if (options.joins?.length) {
169
+ options.joins.forEach((join) => {
170
+ // Handle table aliasing (e.g., 'users as u')
171
+ const tableToJoin = join.as ? `${join.table} as ${join.as}` : join.table;
172
+ switch (join.type) {
173
+ case "inner":
174
+ query.innerJoin(tableToJoin, join.onLeft, join.onRight);
175
+ break;
176
+ case "left":
177
+ query.leftJoin(tableToJoin, join.onLeft, join.onRight);
178
+ break;
179
+ case "right":
180
+ query.rightJoin(tableToJoin, join.onLeft, join.onRight);
181
+ break;
182
+ case "full_outer":
183
+ query.fullOuterJoin(tableToJoin, join.onLeft, join.onRight);
184
+ break;
185
+ }
186
+ });
187
+ }
188
+ // 3. Process Columns (SELECT)
189
+ const selections = options.select.map((col) => {
190
+ if (typeof col === "string") {
191
+ return col;
192
+ }
193
+ if ("func" in col) {
194
+ // Use knex.raw for aggregate functions to prevent SQL injection
195
+ return kx.raw(`${col.func.toUpperCase()}(??) as ??`, [col.column, col.as]);
196
+ }
197
+ // Handle aliasing
198
+ return col.as ? `${col.column} as ${col.as}` : col.column;
199
+ });
200
+ query.select(selections);
201
+ // 4. Process Filters (WHERE), starting with depth 1
202
+ options.where.forEach((group) => applyFilters(query, group, 1));
203
+ // 5. Process Group By
204
+ if (options.groupBy?.length) {
205
+ query.groupBy(options.groupBy);
206
+ }
207
+ // 6. Process Sorts (ORDER BY)
208
+ if (options.orderBy?.length) {
209
+ // Knex's orderBy can take an array of objects directly
210
+ query.orderBy(options.orderBy.map((s) => ({ column: s.column, order: s.direction })));
211
+ }
212
+ // 7. Process Limit
213
+ query.limit(limit);
214
+ // 8. Process Offset
215
+ query.offset(offset);
216
+ return query;
217
+ }