mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
161 lines
6.5 KiB
JavaScript
161 lines
6.5 KiB
JavaScript
// utils/queryBuilder.js
|
|
const { Sequelize, Op } = require("sequelize");
|
|
|
|
// ─── Custom sort order for ENUM fields ────────────────────────────────────────
|
|
const ENUM_SORT_ORDER = {
|
|
acc_type: ["admin", "staff", "user"],
|
|
reg_type: ["system", "google"],
|
|
};
|
|
|
|
// createdBy/updatedBy/deletedBy filter options come from getFieldValues()
|
|
// as { value: user_id, label: full_name } — the filter sheet selects by id,
|
|
// so these need an exact match against the bigint column, never the
|
|
// substring-on-text-cast path used for everything else (that path compares
|
|
// against the raw id and can never match a name-looking value).
|
|
const AUDIT_ID_FIELDS = new Set(["createdBy", "updatedBy", "deletedBy"]);
|
|
|
|
/**
|
|
* Builds a Sequelize `where` clause from an array of filters.
|
|
*
|
|
* @param {Array<{ id: string, value: any }>} filters
|
|
* @param {Set<string>} allowedFields
|
|
* @param {string} [parentAlias] - main model's table alias (e.g. "User"). Needed
|
|
* to qualify Sequelize.col() references — unqualified col() refs throw
|
|
* "column reference is ambiguous" as soon as a query joins another table
|
|
* that happens to share a column name (e.g. Users + UserGroups both have
|
|
* is_active/createdAt/updatedAt/deletedAt).
|
|
* @param {Set<string>} [dateFields] - fields whose picklist values are
|
|
* calendar days (see below) — matched by range, not substring.
|
|
* @returns {Object} Sequelize where clause
|
|
*/
|
|
function buildWhere(filters = [], allowedFields = new Set(), parentAlias = null, dateFields = new Set()) {
|
|
const where = [];
|
|
|
|
for (const { id, value } of filters) {
|
|
if (!id || value === undefined || value === null || value === "") continue;
|
|
if (allowedFields.size && !allowedFields.has(id)) continue;
|
|
|
|
const values = Array.isArray(value) ? value : [value];
|
|
|
|
if (AUDIT_ID_FIELDS.has(id)) {
|
|
where.push({ [id]: { [Op.in]: values } });
|
|
continue;
|
|
}
|
|
|
|
const qualifiedCol = parentAlias ? `${parentAlias}.${id}` : id;
|
|
|
|
// Date/timestamp columns (createdAt, updatedAt, ...) — the filter sheet's
|
|
// picklist values are whole calendar days (e.g. "2026-07-16"), but the
|
|
// column itself is a full timestamp. Casting the timestamp to TEXT and
|
|
// doing a substring iLike match against just the date portion is a loose
|
|
// match: it also picks up every OTHER row whose time-of-day component
|
|
// happens to render into digits that appear elsewhere in the cast string,
|
|
// so a single selected day can silently pull in unrelated rows. Match by
|
|
// an explicit [dayStart, nextDayStart) range on the real column instead —
|
|
// exact, and immune to how the DB happens to stringify the timestamp.
|
|
if (dateFields.has(id) && !id.startsWith("personal_info.")) {
|
|
const conditions = values.map((v) => {
|
|
const datePart = String(v).slice(0, 10);
|
|
const dayStart = new Date(`${datePart}T00:00:00.000Z`);
|
|
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
|
|
|
return Sequelize.where(Sequelize.col(qualifiedCol), {
|
|
[Op.gte]: dayStart,
|
|
[Op.lt]: dayEnd,
|
|
});
|
|
});
|
|
|
|
where.push({ [Op.or]: conditions });
|
|
continue;
|
|
}
|
|
|
|
const conditions = values.map((v) =>
|
|
id.startsWith("personal_info.")
|
|
? Sequelize.where(
|
|
Sequelize.json(`personal_info.${id.replace("personal_info.", "")}`),
|
|
{ [Op.iLike]: `%${v}%` }
|
|
)
|
|
: Sequelize.where(
|
|
Sequelize.cast(Sequelize.col(qualifiedCol), "TEXT"),
|
|
{ [Op.iLike]: `%${v}%` }
|
|
)
|
|
);
|
|
|
|
where.push({ [Op.or]: conditions });
|
|
}
|
|
|
|
return where.length ? { [Op.and]: where } : {};
|
|
}
|
|
|
|
/**
|
|
* Builds a Sequelize `order` clause from an array of sort descriptors.
|
|
* Falls back to [["createdAt", "DESC"]] if no valid sort entries.
|
|
*
|
|
* @param {Array<{ id: string, desc: boolean }>} sort
|
|
* @returns {Array} Sequelize order clause
|
|
*/
|
|
function buildOrder(sort = [], allowedFields = new Set(), computedFields = new Set(), parentAlias = null) {
|
|
const order = [];
|
|
const qualifiedId = (id) => (parentAlias ? `"${parentAlias}"."${id}"` : `"${id}"`);
|
|
|
|
for (const { id, desc } of sort) {
|
|
if (!id) continue;
|
|
if (allowedFields.size && !allowedFields.has(id)) continue;
|
|
|
|
// ─── Computed (subquery/literal) columns — order by the unqualified
|
|
// SELECT alias, since they aren't real columns on the model's table ──
|
|
if (computedFields.has(id)) {
|
|
order.push([Sequelize.literal(`"${id}"`), desc ? "DESC" : "ASC"]);
|
|
continue;
|
|
}
|
|
|
|
// ─── ENUM fields — use CASE for custom sort order ─────────────────────
|
|
if (ENUM_SORT_ORDER[id]) {
|
|
const sequence = desc
|
|
? [...ENUM_SORT_ORDER[id]].reverse()
|
|
: ENUM_SORT_ORDER[id];
|
|
|
|
const caseExpr = sequence
|
|
.map((val, i) => `WHEN '${val}' THEN ${i}`)
|
|
.join(" ");
|
|
|
|
order.push([Sequelize.literal(`CASE ${qualifiedId(id)} ${caseExpr} END`)]);
|
|
continue;
|
|
}
|
|
|
|
// ─── JSONB dot-notation fields ─────────────────────────────────────────
|
|
if (id.startsWith("personal_info.")) {
|
|
order.push([Sequelize.json(id), desc ? "DESC" : "ASC"]);
|
|
continue;
|
|
}
|
|
|
|
// ─── Regular fields ───────────────────────────────────────────────────
|
|
order.push([id, desc ? "DESC" : "ASC"]);
|
|
}
|
|
|
|
return order.length ? order : [["createdAt", "DESC"]];
|
|
}
|
|
|
|
/**
|
|
* Convenience wrapper — returns both where and order in one call.
|
|
*
|
|
* @param {Array} filters
|
|
* @param {Array} sort
|
|
* @param {Array} [dateFields] - fields to match by day-range instead of substring
|
|
* @returns {{ where: Object, order: Array }}
|
|
*/
|
|
function buildQuery(filters = [], sort = [], allowedFields = [], computedFields = [], parentAlias = null, dateFields = []) {
|
|
const fieldSet = new Set(allowedFields);
|
|
const computedSet = new Set(computedFields);
|
|
const orderFieldSet = new Set([...allowedFields, ...computedFields]);
|
|
const dateFieldSet = new Set(dateFields);
|
|
|
|
return {
|
|
// Computed (subquery/literal) columns aren't real table columns — filtering
|
|
// via Sequelize.col() would error, so only allow them in ORDER BY, not WHERE.
|
|
where: buildWhere(filters, fieldSet, parentAlias, dateFieldSet),
|
|
order: buildOrder(sort, orderFieldSet, computedSet, parentAlias),
|
|
};
|
|
}
|
|
|
|
module.exports = { buildWhere, buildOrder, buildQuery }; |