@dbcube/query-builder 5.1.5 → 5.1.6

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/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/lib/Database.ts
2
2
  import fs from "fs";
3
+ import path2 from "path";
3
4
  import { QueryEngine, ComputedFieldProcessor, TriggerProcessor } from "@dbcube/core";
4
5
 
5
6
  // src/lib/Trigger.ts
@@ -53,6 +54,7 @@ var Database = class _Database {
53
54
  engine;
54
55
  computedFields;
55
56
  triggers;
57
+ txId = null;
56
58
  constructor(name) {
57
59
  this.name = name;
58
60
  const engine = new QueryEngine(name);
@@ -60,6 +62,54 @@ var Database = class _Database {
60
62
  this.computedFields = [];
61
63
  this.triggers = [];
62
64
  }
65
+ /**
66
+ * Executes raw SQL (MySQL/PostgreSQL/SQLite) or a raw command document (MongoDB)
67
+ * with bound parameters. The escape hatch for anything the builder doesn't cover.
68
+ *
69
+ * @example
70
+ * const rows = await db.raw('SELECT * FROM users WHERE age > ?', [25]);
71
+ * await db.raw('CREATE INDEX idx_users_email ON users(email)');
72
+ */
73
+ async raw(query, params = []) {
74
+ const response = await this.engine.rawQuery(query, params, this.txId ?? void 0);
75
+ if (response.status != 200) {
76
+ returnFormattedError(response.status, response.message);
77
+ throw new Error(response.message);
78
+ }
79
+ return response.data;
80
+ }
81
+ /**
82
+ * Runs a callback inside a database transaction. Everything executed through
83
+ * the received connection is committed atomically; any thrown error rolls
84
+ * the whole transaction back.
85
+ *
86
+ * Requires daemon mode (enabled by default with an up-to-date query-engine).
87
+ *
88
+ * @example
89
+ * await db.transaction(async (trx) => {
90
+ * await trx.table('accounts').where('id', '=', 1).update({ balance: 50 });
91
+ * await trx.table('accounts').where('id', '=', 2).update({ balance: 150 });
92
+ * });
93
+ */
94
+ async transaction(callback) {
95
+ const txId = await this.engine.beginTransaction();
96
+ const trx = new _Database(this.name);
97
+ trx.engine = this.engine;
98
+ trx.computedFields = this.computedFields;
99
+ trx.triggers = this.triggers;
100
+ trx.txId = txId;
101
+ try {
102
+ const result = await callback(trx);
103
+ await this.engine.commitTransaction(txId);
104
+ return result;
105
+ } catch (error) {
106
+ try {
107
+ await this.engine.rollbackTransaction(txId);
108
+ } catch {
109
+ }
110
+ throw error;
111
+ }
112
+ }
63
113
  async useComputes() {
64
114
  const newDatabase = new _Database(this.name);
65
115
  const arrayComputedFields = await ComputedFieldProcessor.getComputedFields(this.name);
@@ -126,7 +176,7 @@ var Database = class _Database {
126
176
  * const columns = await db.table('users').columns().get();
127
177
  */
128
178
  table(tableName) {
129
- return new Table(this, this.name, tableName, this.engine, this.computedFields, this.triggers);
179
+ return new Table(this, this.name, tableName, this.engine, this.computedFields, this.triggers, this.txId);
130
180
  }
131
181
  setComputedFields(computedFields) {
132
182
  this.computedFields = computedFields;
@@ -142,12 +192,15 @@ var Table = class _Table {
142
192
  computedFields = [];
143
193
  trigger;
144
194
  triggers;
145
- constructor(instance, databaseName, tableName, engine = null, computedFields = [], triggers = []) {
195
+ txId = null;
196
+ relations = [];
197
+ constructor(instance, databaseName, tableName, engine = null, computedFields = [], triggers = [], txId = null) {
146
198
  this.engine = engine;
147
199
  this.computedFields = computedFields;
148
200
  this.triggers = triggers;
149
201
  this.trigger = new Trigger(instance, databaseName, triggers);
150
202
  this.nextType = "AND";
203
+ this.txId = txId;
151
204
  this.dml = {
152
205
  type: "select",
153
206
  database: databaseName,
@@ -161,7 +214,8 @@ var Table = class _Table {
161
214
  limit: null,
162
215
  offset: null,
163
216
  data: null,
164
- aggregation: null
217
+ aggregation: null,
218
+ having: []
165
219
  };
166
220
  }
167
221
  /**
@@ -723,6 +777,9 @@ var Table = class _Table {
723
777
  clone.dml.data = null;
724
778
  clone.dml.aggregation = null;
725
779
  const result = await clone.getResponse();
780
+ if (this.relations.length > 0 && Array.isArray(result)) {
781
+ return await this.attachRelations(result);
782
+ }
726
783
  return result;
727
784
  } catch (error) {
728
785
  throw error;
@@ -844,6 +901,310 @@ var Table = class _Table {
844
901
  const deleteData = await clone.getResponse(clone.dml, "Delete");
845
902
  return deleteData;
846
903
  }
904
+ /**
905
+ * Adds a WHERE NOT IN condition to the query.
906
+ *
907
+ * @example
908
+ * const users = await db.table('users').whereNotIn('status', ['banned', 'deleted']).get();
909
+ */
910
+ whereNotIn(column, values) {
911
+ const clone = this.clone();
912
+ if (Array.isArray(values) && values.length > 0) {
913
+ clone.dml.where.push({
914
+ column,
915
+ operator: "NOT IN",
916
+ value: values,
917
+ type: clone.nextType,
918
+ isGroup: false
919
+ });
920
+ clone.nextType = "AND";
921
+ }
922
+ return clone;
923
+ }
924
+ /**
925
+ * Sets an explicit OFFSET for the query (alternative to page()).
926
+ *
927
+ * @example
928
+ * const rows = await db.table('logs').orderBy('id', 'ASC').limit(50).offset(100).get();
929
+ */
930
+ offset(number) {
931
+ const clone = this.clone();
932
+ clone.dml.offset = Number(number);
933
+ return clone;
934
+ }
935
+ /**
936
+ * Appends raw expressions to the SELECT list (aggregates, functions, aliases).
937
+ * Combine with groupBy() and having() for per-group metrics.
938
+ *
939
+ * @example
940
+ * const stats = await db.table('orders')
941
+ * .select(['user_id'])
942
+ * .selectRaw(['COUNT(*) AS order_count', 'SUM(amount) AS total'])
943
+ * .groupBy('user_id')
944
+ * .having('order_count', '>', 5)
945
+ * .get();
946
+ */
947
+ selectRaw(expressions) {
948
+ const clone = this.clone();
949
+ if (clone.dml.columns.length === 1 && clone.dml.columns[0] === "*") {
950
+ clone.dml.columns = [...expressions];
951
+ } else {
952
+ clone.dml.columns = [...clone.dml.columns, ...expressions];
953
+ }
954
+ return clone;
955
+ }
956
+ /**
957
+ * Adds a HAVING condition (filters grouped results).
958
+ *
959
+ * @example
960
+ * .groupBy('user_id').having('COUNT(*)', '>', 5)
961
+ */
962
+ having(column, operator, value) {
963
+ const clone = this.clone();
964
+ clone.dml.having = clone.dml.having ?? [];
965
+ clone.dml.having.push({
966
+ column,
967
+ operator,
968
+ value,
969
+ type: "AND",
970
+ isGroup: false
971
+ });
972
+ return clone;
973
+ }
974
+ /**
975
+ * Checks if at least one row matches the current conditions.
976
+ * Cheaper than first(): selects a constant with LIMIT 1.
977
+ *
978
+ * @example
979
+ * const taken = await db.table('users').where('email', '=', email).exists();
980
+ */
981
+ async exists() {
982
+ const clone = this.clone();
983
+ clone.dml.type = "select";
984
+ clone.dml.columns = ["1 AS dbcube_exists"];
985
+ clone.dml.data = null;
986
+ clone.dml.aggregation = null;
987
+ clone.dml.limit = 1;
988
+ clone.dml.offset = null;
989
+ const result = await clone.getResponse();
990
+ return Array.isArray(result) && result.length > 0;
991
+ }
992
+ /**
993
+ * Fetches one page of results plus pagination metadata in a single call.
994
+ * Runs the page query and the total count with the same conditions.
995
+ *
996
+ * @example
997
+ * const { items, total, totalPages, hasNext } = await db.table('products')
998
+ * .where('published', '=', true)
999
+ * .orderBy('id', 'ASC')
1000
+ * .paginate(2, 25);
1001
+ */
1002
+ async paginate(page = 1, perPage = 20) {
1003
+ const pageNum = Math.max(1, Number(page));
1004
+ const size = Math.max(1, Number(perPage));
1005
+ const itemsQuery = this.limit(size).page(pageNum);
1006
+ const [items, total] = await Promise.all([
1007
+ itemsQuery.get(),
1008
+ this.count()
1009
+ ]);
1010
+ const totalNum = Number(total);
1011
+ const totalPages = Math.ceil(totalNum / size);
1012
+ return {
1013
+ items,
1014
+ page: pageNum,
1015
+ perPage: size,
1016
+ total: totalNum,
1017
+ totalPages,
1018
+ hasNext: pageNum < totalPages,
1019
+ hasPrev: pageNum > 1
1020
+ };
1021
+ }
1022
+ /**
1023
+ * Processes all matching rows in batches of `size`, keeping memory flat.
1024
+ * Return `false` from the callback to stop early.
1025
+ *
1026
+ * @example
1027
+ * await db.table('logs').orderBy('id', 'ASC').chunk(500, async (rows) => {
1028
+ * await processBatch(rows);
1029
+ * });
1030
+ */
1031
+ async chunk(size, callback) {
1032
+ const batchSize = Math.max(1, Number(size));
1033
+ let page = 1;
1034
+ while (true) {
1035
+ const rows = await this.limit(batchSize).page(page).get();
1036
+ if (rows.length === 0) break;
1037
+ const result = await callback(rows, page);
1038
+ if (result === false) break;
1039
+ if (rows.length < batchSize) break;
1040
+ page++;
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Atomically increments a numeric column: `SET col = col + amount`.
1045
+ * Requires at least one WHERE condition. Extra fields can be updated in the same statement.
1046
+ *
1047
+ * @example
1048
+ * await db.table('products').where('id', '=', 5).increment('stock', 3);
1049
+ * await db.table('posts').where('id', '=', 1).increment('views', 1, { last_viewed_at: new Date().toISOString() });
1050
+ */
1051
+ async increment(column, amount = 1, extra = {}) {
1052
+ const clone = this.clone();
1053
+ if (clone.dml.where.length === 0) {
1054
+ throw new Error("You must specify at least one WHERE condition to perform an increment.");
1055
+ }
1056
+ clone.dml.type = "update";
1057
+ clone.dml.data = { ...extra, [column]: { $inc: Number(amount) } };
1058
+ return clone.getResponse();
1059
+ }
1060
+ /**
1061
+ * Atomically decrements a numeric column: `SET col = col - amount`.
1062
+ *
1063
+ * @example
1064
+ * await db.table('products').where('id', '=', 5).decrement('stock', 1);
1065
+ */
1066
+ async decrement(column, amount = 1, extra = {}) {
1067
+ return this.increment(column, -Math.abs(Number(amount)), extra);
1068
+ }
1069
+ /**
1070
+ * Deletes ALL rows from the table. The only write operation allowed
1071
+ * without a WHERE — the destructive intent is explicit in the name.
1072
+ *
1073
+ * @example
1074
+ * await db.table('temp_imports').truncate();
1075
+ */
1076
+ async truncate() {
1077
+ const clone = this.clone();
1078
+ clone.dml.type = "delete";
1079
+ clone.dml.where = [];
1080
+ return clone.getResponse();
1081
+ }
1082
+ /**
1083
+ * Inserts rows, updating them instead when a conflict occurs on the given keys.
1084
+ * MySQL → ON DUPLICATE KEY UPDATE · PostgreSQL/SQLite → ON CONFLICT ... DO UPDATE.
1085
+ *
1086
+ * @param data Rows to insert.
1087
+ * @param conflictKeys Column(s) with the UNIQUE/PK constraint that triggers the update.
1088
+ * @param updateColumns Columns to overwrite on conflict (defaults to every non-conflict column).
1089
+ *
1090
+ * @example
1091
+ * await db.table('settings').upsert(
1092
+ * [{ key: 'theme', value: 'dark' }],
1093
+ * ['key']
1094
+ * );
1095
+ */
1096
+ async upsert(data, conflictKeys, updateColumns) {
1097
+ if (!Array.isArray(data) || data.length === 0) {
1098
+ throw new Error("The upsert method requires a non-empty array of objects.");
1099
+ }
1100
+ if (!Array.isArray(conflictKeys) || conflictKeys.length === 0) {
1101
+ throw new Error("The upsert method requires at least one conflict key column.");
1102
+ }
1103
+ const motor = this.engine?.getConfig?.()?.type ?? "mysql";
1104
+ if (motor === "mongodb") {
1105
+ throw new Error("upsert() is not supported for MongoDB yet. Use update() + insert() or raw commands.");
1106
+ }
1107
+ const table = this.dml.table;
1108
+ const columns = Object.keys(data[0]);
1109
+ const targets = updateColumns ?? columns.filter((c) => !conflictKeys.includes(c));
1110
+ const quote = (id) => motor === "mysql" ? `\`${id}\`` : `"${id}"`;
1111
+ const params = [];
1112
+ const placeholder = () => motor === "postgres" || motor === "postgresql" ? `$${params.length}` : "?";
1113
+ const rowsSql = data.map((row) => {
1114
+ const cells = columns.map((col) => {
1115
+ params.push(row[col] === void 0 ? null : row[col]);
1116
+ return placeholder();
1117
+ });
1118
+ return `(${cells.join(", ")})`;
1119
+ }).join(", ");
1120
+ const colsSql = columns.map(quote).join(", ");
1121
+ let sql;
1122
+ if (motor === "mysql") {
1123
+ const updates = targets.map((c) => `${quote(c)} = VALUES(${quote(c)})`).join(", ");
1124
+ sql = `INSERT INTO ${quote(table)} (${colsSql}) VALUES ${rowsSql} ON DUPLICATE KEY UPDATE ${updates}`;
1125
+ } else {
1126
+ const conflict = conflictKeys.map(quote).join(", ");
1127
+ const updates = targets.map((c) => `${quote(c)} = excluded.${quote(c)}`).join(", ");
1128
+ sql = `INSERT INTO ${quote(table)} (${colsSql}) VALUES ${rowsSql} ON CONFLICT (${conflict}) DO UPDATE SET ${updates}`;
1129
+ }
1130
+ const response = await this.engine.rawQuery(sql, params, this.txId ?? void 0);
1131
+ if (response.status != 200) {
1132
+ returnFormattedError(response.status, response.message);
1133
+ throw new Error(response.message);
1134
+ }
1135
+ return response.data;
1136
+ }
1137
+ /**
1138
+ * Declares a relation to eager-load with the results of get().
1139
+ * Relations are resolved from `foreign` definitions in your .cube files,
1140
+ * or explicitly via options. Loads each relation with ONE batched query
1141
+ * (whereIn) — no N+1.
1142
+ *
1143
+ * @example
1144
+ * // hasMany: orders.user_id → users.id (auto-detected from orders.table.cube)
1145
+ * const users = await db.table('users').with('orders').get();
1146
+ * // users[0].orders === [{...}, {...}]
1147
+ *
1148
+ * // belongsTo: posts.author_id → users.id, attached as a single object
1149
+ * const posts = await db.table('posts')
1150
+ * .with('author', { table: 'users', foreignKey: 'author_id', type: 'one' })
1151
+ * .get();
1152
+ */
1153
+ with(relation, options = {}) {
1154
+ const clone = this.clone();
1155
+ clone.relations.push({ name: relation, options });
1156
+ return clone;
1157
+ }
1158
+ async attachRelations(rows) {
1159
+ if (rows.length === 0 || this.relations.length === 0) return rows;
1160
+ for (const { name, options } of this.relations) {
1161
+ const relatedTable = options.table ?? name;
1162
+ const cubeMeta = loadCubeRelations();
1163
+ let type = options.type;
1164
+ let foreignKey = options.foreignKey;
1165
+ let localKey = options.localKey ?? "id";
1166
+ if (!foreignKey || !type) {
1167
+ const childFks = cubeMeta[relatedTable]?.foreigns ?? [];
1168
+ const ownFks = cubeMeta[this.dml.table]?.foreigns ?? [];
1169
+ const childToParent = childFks.find((fk) => fk.table === this.dml.table);
1170
+ const parentToChild = ownFks.find((fk) => fk.table === relatedTable);
1171
+ if (childToParent) {
1172
+ type = type ?? "many";
1173
+ foreignKey = foreignKey ?? childToParent.column_name;
1174
+ localKey = options.localKey ?? childToParent.column;
1175
+ } else if (parentToChild) {
1176
+ type = type ?? "one";
1177
+ foreignKey = foreignKey ?? parentToChild.column_name;
1178
+ localKey = options.localKey ?? parentToChild.column;
1179
+ } else {
1180
+ type = type ?? "many";
1181
+ foreignKey = foreignKey ?? `${singularize(this.dml.table)}_id`;
1182
+ }
1183
+ }
1184
+ const relatedQuery = new _Table(this, this.dml.database, relatedTable, this.engine, this.computedFields, this.triggers, this.txId);
1185
+ if (type === "one") {
1186
+ const fkValues = [...new Set(rows.map((r) => r[foreignKey]).filter((v) => v !== null && v !== void 0))];
1187
+ const related = fkValues.length > 0 ? await relatedQuery.whereIn(localKey, fkValues).get() : [];
1188
+ const index = new Map(related.map((r) => [r[localKey], r]));
1189
+ for (const row of rows) {
1190
+ row[name] = index.get(row[foreignKey]) ?? null;
1191
+ }
1192
+ } else {
1193
+ const ids = [...new Set(rows.map((r) => r[localKey]).filter((v) => v !== null && v !== void 0))];
1194
+ const related = ids.length > 0 ? await relatedQuery.whereIn(foreignKey, ids).get() : [];
1195
+ const groups = /* @__PURE__ */ new Map();
1196
+ for (const rel of related) {
1197
+ const key = rel[foreignKey];
1198
+ if (!groups.has(key)) groups.set(key, []);
1199
+ groups.get(key).push(rel);
1200
+ }
1201
+ for (const row of rows) {
1202
+ row[name] = groups.get(row[localKey]) ?? [];
1203
+ }
1204
+ }
1205
+ }
1206
+ return rows;
1207
+ }
847
1208
  async getResponse(dml = null, type = null) {
848
1209
  const localDML = dml ? dml : this.dml;
849
1210
  const computedFieldsNeeded = [];
@@ -873,12 +1234,7 @@ var Table = class _Table {
873
1234
  const newDML = { ...localDML, data: [data] };
874
1235
  if (beffore) {
875
1236
  const interceptor = await this.trigger.execute("before" + type, data);
876
- const response = await this.engine.run("query_engine", [
877
- "--action",
878
- "execute",
879
- "--dml",
880
- JSON.stringify(newDML)
881
- ]);
1237
+ const response = await this.engine.executeDml(newDML, this.txId ?? void 0);
882
1238
  if (response.status != 200) {
883
1239
  interceptor.discard();
884
1240
  returnFormattedError(response.status, response.message);
@@ -887,12 +1243,7 @@ var Table = class _Table {
887
1243
  arrayResult = response.data;
888
1244
  }
889
1245
  if (after) {
890
- const response = await this.engine.run("query_engine", [
891
- "--action",
892
- "execute",
893
- "--dml",
894
- JSON.stringify(newDML)
895
- ]);
1246
+ const response = await this.engine.executeDml(newDML, this.txId ?? void 0);
896
1247
  if (response.status != 200) {
897
1248
  returnFormattedError(response.status, response.message);
898
1249
  }
@@ -901,24 +1252,14 @@ var Table = class _Table {
901
1252
  }
902
1253
  }
903
1254
  } else {
904
- const response = await this.engine.run("query_engine", [
905
- "--action",
906
- "execute",
907
- "--dml",
908
- JSON.stringify(localDML)
909
- ]);
1255
+ const response = await this.engine.executeDml(localDML, this.txId ?? void 0);
910
1256
  if (response.status != 200) {
911
1257
  returnFormattedError(response.status, response.message);
912
1258
  }
913
1259
  arrayResult = response.data;
914
1260
  }
915
1261
  } else {
916
- const response = await this.engine.run("query_engine", [
917
- "--action",
918
- "execute",
919
- "--dml",
920
- JSON.stringify(localDML)
921
- ]);
1262
+ const response = await this.engine.executeDml(localDML, this.txId ?? void 0);
922
1263
  if (response.status != 200) {
923
1264
  returnFormattedError(response.status, response.message);
924
1265
  }
@@ -942,6 +1283,8 @@ var Table = class _Table {
942
1283
  cloned.computedFields = this.computedFields;
943
1284
  cloned.trigger = this.trigger;
944
1285
  cloned.triggers = this.triggers;
1286
+ cloned.txId = this.txId;
1287
+ cloned.relations = [...this.relations];
945
1288
  cloned.dml = {
946
1289
  ...this.dml,
947
1290
  columns: [...this.dml.columns],
@@ -949,6 +1292,7 @@ var Table = class _Table {
949
1292
  where: [...this.dml.where],
950
1293
  orderBy: [...this.dml.orderBy],
951
1294
  groupBy: [...this.dml.groupBy],
1295
+ having: [...this.dml.having ?? []],
952
1296
  // Clonar propiedades que faltaban para evitar mutación compartida
953
1297
  data: this.dml.data ? Array.isArray(this.dml.data) ? [...this.dml.data] : { ...this.dml.data } : null,
954
1298
  aggregation: this.dml.aggregation ? { ...this.dml.aggregation } : null
@@ -956,6 +1300,58 @@ var Table = class _Table {
956
1300
  return cloned;
957
1301
  }
958
1302
  };
1303
+ var cubeRelationsCache = null;
1304
+ function loadCubeRelations() {
1305
+ if (cubeRelationsCache) return cubeRelationsCache;
1306
+ const result = {};
1307
+ const scanDir = (dir) => {
1308
+ let entries;
1309
+ try {
1310
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1311
+ } catch {
1312
+ return;
1313
+ }
1314
+ for (const entry of entries) {
1315
+ const full = path2.join(dir, entry.name);
1316
+ if (entry.isDirectory()) {
1317
+ if (entry.name !== "node_modules" && entry.name !== "triggers" && entry.name !== "logs") {
1318
+ scanDir(full);
1319
+ }
1320
+ } else if (entry.name.endsWith(".table.cube")) {
1321
+ try {
1322
+ const content = fs.readFileSync(full, "utf8");
1323
+ const nameMatch = content.match(/@meta\s*\(\s*\{[\s\S]*?name\s*:\s*"([^"]+)"/);
1324
+ const tableName = nameMatch ? nameMatch[1] : path2.basename(entry.name, ".table.cube");
1325
+ const foreigns = [];
1326
+ const colRegex = /(\w+)\s*:\s*\{(?:[^{}]|\{[^{}]*\})*?foreign\s*:\s*\{([^}]*)\}/g;
1327
+ let m;
1328
+ while ((m = colRegex.exec(content)) !== null) {
1329
+ const fkTable = m[2].match(/table\s*:\s*"([^"]+)"/);
1330
+ const fkColumn = m[2].match(/column\s*:\s*"([^"]+)"/);
1331
+ if (fkTable) {
1332
+ foreigns.push({
1333
+ column_name: m[1],
1334
+ table: fkTable[1],
1335
+ column: fkColumn ? fkColumn[1] : "id"
1336
+ });
1337
+ }
1338
+ }
1339
+ result[tableName] = { foreigns };
1340
+ } catch {
1341
+ }
1342
+ }
1343
+ }
1344
+ };
1345
+ scanDir(path2.join(process.cwd(), "dbcube"));
1346
+ cubeRelationsCache = result;
1347
+ return result;
1348
+ }
1349
+ function singularize(word) {
1350
+ if (word.endsWith("ies")) return word.slice(0, -3) + "y";
1351
+ if (word.endsWith("ses")) return word.slice(0, -2);
1352
+ if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
1353
+ return word;
1354
+ }
959
1355
  function returnFormattedError(status, message) {
960
1356
  const RESET = "\x1B[0m";
961
1357
  const RED = "\x1B[31m";