From c54b163be227f5abf946df6a5da41c1d84b08672 Mon Sep 17 00:00:00 2001
From: Chord
Date: Tue, 31 Dec 2019 15:25:35 -0500
Subject: [PATCH] Enable sorting of account list by column
---
api/admin.js | 27 ++++++-
api/db.js | 89 +++++++++++++++++----
api/util.js | 100 +++++++++++++++++++++++-
app/components/PaginatedList.svelte | 7 +-
app/views/Profile.svelte | 2 +-
app/views/UserList.svelte | 117 +++++++++++++++++++++++++---
scss/base.scss | 24 ++++++
7 files changed, 333 insertions(+), 33 deletions(-)
diff --git a/api/admin.js b/api/admin.js
index c095ee8..6e07c6d 100644
--- a/api/admin.js
+++ b/api/admin.js
@@ -1,6 +1,6 @@
import express from 'express'
import * as db from './db.js'
-import { get_pagination, fetch_user_middleware } from './util.js'
+import { get_pagination, get_filter, get_sort, fetch_user_middleware } from './util.js'
const api = express.Router();
@@ -8,9 +8,32 @@ api.param("user", fetch_user_middleware);
api.get('/users', async (req, res, next) => {
const pagination = get_pagination(req);
+ const filter = get_filter(req,
+ {
+ param : 'filter',
+ default : 'all',
+ types : {
+ 'gm' : { [db.ACCOUNT.ADMIN] : true },
+ 'banned' : { [db.ACCOUNT.BANNED] : true },
+ }
+ }
+ );
+
+ const sort = get_sort(req,
+ {
+ param : 'sort',
+ default : 'created_desc',
+ types : {
+ 'id' : db.ACCOUNT.ID,
+ 'created' : db.ACCOUNT.CREATED,
+ 'username' : db.ACCOUNT.USER,
+ 'last_login' : db.ACCOUNT.LAST_LOGIN,
+ }
+ }
+ );
try {
- const accounts = await db.get_accounts_login_info(pagination, db.ACCOUNT.CREATED, db.SQL_ORDER.DESCENDING);
+ const accounts = await db.get_accounts_login_info(pagination, sort, filter);
res.status(200).json({ users: accounts, page: pagination})
} catch (e) {
console.log(e)
diff --git a/api/db.js b/api/db.js
index aa0493f..e89025f 100644
--- a/api/db.js
+++ b/api/db.js
@@ -39,6 +39,9 @@ export const ACCOUNT = Object.freeze({
MODIFIED: Symbol("last_modified"),
BANNED: Symbol("inactive"),
ADMIN: Symbol("gm"),
+
+ // A derived table column
+ LAST_LOGIN: Symbol("last_login"),
});
export const CHARACTER = Object.freeze({
@@ -63,7 +66,8 @@ export const LOGIN = Object.freeze({
});
function to_sql(symbol) {
- assert(typeof symbol == 'symbol')
+ assert(typeof symbol == 'symbol',
+ `symbol expected got ${typeof symbol}`)
return String(symbol).slice(7,-1);
}
@@ -72,7 +76,9 @@ function to_sql_kv(fields, idx=1) {
let values = [];
// This will ONLY get Symbols in the field dict
- assert(Object.getOwnPropertySymbols(fields).length > 0, "to_sql_kv must have at least one field")
+ if (!fields || Object.getOwnPropertySymbols(fields).length == 0) {
+ return { sql : [], next_idx: idx, values: [] }
+ }
Object.getOwnPropertySymbols(fields).forEach(key => {
assert(typeof key == 'symbol')
@@ -89,22 +95,64 @@ function to_sql_kv(fields, idx=1) {
function build_SET(fields, idx=1) {
const kv = to_sql_kv(fields, idx);
- kv.sql = Symbol(kv.sql.join(", "));
+
+ assert(kv.sql.length > 0, "SET MUST have at least one kv pair")
+
+ kv.sql = Symbol(`SET ${kv.sql.join(", ")}`);
+
return kv;
}
-function build_WHERE(fields, idx=1) {
- const kv = to_sql_kv(fields, idx);
- kv.sql = Symbol(kv.sql.join(" AND "));
+function build_WHERE(filter, idx=1) {
+ const kv = to_sql_kv(filter.fields, idx);
+
+ if (kv.sql.length > 1) {
+ assert(filter.binop !== undefined,
+ "binary operand required in WHERE with multiple terms")
+ const binop = filter.binop == "AND" ? Symbol("AND") : Symbol("OR");
+ kv.sql = Symbol(`WHERE ${kv.sql.join(` ${to_sql(binop)} `)}`);
+ } else if (kv.sql.length == 1)
+ kv.sql = Symbol(`WHERE ${kv.sql[0]}`);
+ else
+ kv.sql = Symbol("")
+
return kv;
}
+function build_ORDER_BY(sort) {
+ // This will ONLY get Symbols in the sort dict
+ if (!sort || Object.getOwnPropertySymbols(sort).length == 0) {
+ return Symbol("");
+ }
+
+ let SQL = []
+ Object.getOwnPropertySymbols(sort).forEach(key => {
+ const value = sort[key];
+ assert(typeof key == 'symbol')
+ assert(typeof value == 'symbol')
+ SQL.push(`${to_sql(key)} ${to_sql(value)}`);
+ });
+
+ return Symbol(`ORDER BY ${SQL.join(", ")}`);
+}
+
+function build_OFFSET(offset, limit, idx=1) {
+ assert(typeof offset == 'number', "offset must be a number");
+ assert(typeof limit == 'number', "limit must be a number");
+
+ return {
+ sql: Symbol(`OFFSET $${idx++} LIMIT $${idx++}`),
+ next_idx: idx,
+ values: [offset, limit],
+ };
+}
+
async function get_row_count(table, filter=undefined) {
let resp;
if (filter) {
const where = build_WHERE(filter);
- resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)} WHERE ${to_sql(where.sql)}`,
+ resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)} ${to_sql(where.sql)}`,
where.values);
} else {
resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)}`);
@@ -171,17 +219,21 @@ export async function get_accounts(pagination, sort, order) {
}
}
-export async function get_accounts_login_info(pagination, sort, order) {
+export async function get_accounts_login_info(pagination, sort, filter) {
const start_id = (pagination.page-1)*pagination.items_per_page;
const values = [start_id, pagination.items_per_page];
try {
- const account_count = await get_row_count(ACCOUNT.THIS);
+ const account_count = await get_row_count(ACCOUNT.THIS, filter);
+ const where = build_WHERE(filter);
+ const offset = build_OFFSET(start_id, pagination.items_per_page, where.next_idx);
+ const values = [].concat(where.values, offset.values);
+ const order = build_ORDER_BY(sort);
// this was a really hard query to get right...
// https://www.gab.lc/articles/better_faster_subqueries_postgresql/
const accounts = await pool.query(
- 'SELECT accounts.*, lastLogin as login_time, l2.ip_address, l2.canonical_hostname FROM accounts' +
+ 'SELECT accounts.*, COALESCE(l.lastLogin, TIMESTAMP \'epoch\') as last_login, l2.ip_address, l2.canonical_hostname FROM accounts' +
' LEFT OUTER JOIN (' +
' SELECT MAX(id) as loginId, account_id, MAX(login_time) as lastLogin' +
' FROM logins' +
@@ -189,7 +241,9 @@ export async function get_accounts_login_info(pagination, sort, order) {
' ) l ON l.account_id = accounts.id' +
' LEFT OUTER JOIN logins l2' +
' ON l2.id = l.loginId' +
- ` ORDER BY COALESCE(l.lastLogin, TIMESTAMP \'epoch\') ${to_sql(order)} OFFSET $1 LIMIT $2`, values);
+ ` ${to_sql(where.sql)}` +
+ ` ${to_sql(order)}` +
+ ` ${to_sql(offset.sql)}`, values);
pagination.item_count = account_count;
pagination.page_count = Math.ceil(pagination.item_count / pagination.items_per_page);
@@ -198,9 +252,9 @@ export async function get_accounts_login_info(pagination, sort, order) {
r.name = r.username;
r.admin = r.gm;
- if (r.login_time !== null) {
+ if (r.ip_address !== null) {
r.last_login = {
- time : r.login_time,
+ time : r.last_login,
hostname : r.canonical_hostname,
ip : r.ip_address,
}
@@ -208,7 +262,6 @@ export async function get_accounts_login_info(pagination, sort, order) {
r.last_login = {}
}
- delete r.login_time;
delete r.canonical_hostname;
delete r.ip_address;
delete r.passhash;
@@ -285,7 +338,7 @@ export async function update_account(account_id, fields) {
set.values.push(account_id)
try {
- const update_result = await pool.query(`UPDATE accounts SET ${to_sql(set.sql)} WHERE id=$${set.next_idx}`, set.values);
+ const update_result = await pool.query(`UPDATE accounts ${to_sql(set.sql)} WHERE id=$${set.next_idx}`, set.values);
return update_result.rowCount;
} catch (e) {
if (e.code)
@@ -324,7 +377,11 @@ export async function get_account_logins(account_id, pagination) {
const values = [account_id, start_id, pagination.items_per_page];
try {
- const login_count = await get_row_count(LOGIN.THIS, { [LOGIN.ACCOUNT_ID] : account_id });
+ const login_count = await get_row_count(LOGIN.THIS, {
+ fields : {
+ [LOGIN.ACCOUNT_ID] : account_id,
+ }
+ });
const logins = await pool.query('SELECT * FROM logins WHERE account_id=$1 ORDER by login_time DESC ' + ` OFFSET $2 LIMIT $3`, values);
pagination.item_count = login_count;
diff --git a/api/util.js b/api/util.js
index f8fa9b3..c89e51c 100644
--- a/api/util.js
+++ b/api/util.js
@@ -1,8 +1,8 @@
import * as db from './db.js'
+import assert from 'assert'
export function get_pagination(req) {
let page = parseInt(req.query.page);
- let order = req.query.order; // TODO
if (!page || page < 1) {
page = 1;
@@ -11,10 +11,106 @@ export function get_pagination(req) {
return {
page: page,
items_per_page: 40,
- //order: order, // TODO
};
}
+export function get_sort_param(param) {
+ const tokens = param.split("_");
+ let order = db.SQL_ORDER.ASCENDING;
+
+ // default to ascending if malformed
+ if (tokens[tokens.length-1] == "desc")
+ order = db.SQL_ORDER.DESCENDING;
+ else if (tokens[tokens.length-1] == "asc")
+ order = db.SQL_ORDER.ASCENDING;
+
+ return [tokens.slice(0, tokens.length-1).join("_"), order]
+}
+
+export function get_sort(req, sort_spec) {
+ assert(sort_spec && sort_spec.param && sort_spec.default && sort_spec.types,
+ "Invalid filter specification");
+ assert(sort_spec.default, "Default sort specification required");
+
+ Object.keys(sort_spec.types).forEach((k) => {
+ assert(typeof k == 'string', "Sort key spec must be a string")
+ assert(typeof sort_spec.types[k] == 'symbol', "Sort value MUST be a symbolic table column")
+ });
+
+ const default_sort_parsed = get_sort_param(sort_spec.default);
+ assert(default_sort_parsed[0] in sort_spec.types, "Default sort type MUST be in spec")
+
+ const default_sort = { [sort_spec.types[default_sort_parsed[0]]] : default_sort_parsed[1] };
+
+ const sort_query = req.query[sort_spec.param];
+
+ // return default
+ if (!sort_query) {
+ return default_sort;
+ }
+
+ const sort = {}
+ const types = sort_query.split(",");
+
+ for (let i = 0; i < types.length; i++) {
+ const t = types[i];
+ const sparsed = get_sort_param(t);
+
+ if (!(sparsed[0] in sort_spec.types)) {
+ console.log("WARNING: unknown sort type " + t);
+ return default_sort;
+ }
+
+ const column_name = sort_spec.types[sparsed[0]];
+ sort[column_name] = sparsed[1];
+ }
+
+ return sort;
+}
+
+export function get_filter(req, filter_spec) {
+ assert(filter_spec && filter_spec.param && filter_spec.default && filter_spec.types,
+ "Invalid filter specification");
+
+ const filter_query = req.query[filter_spec.param];
+
+ if (!filter_query) {
+ return {};
+ }
+
+ const types = filter_query.split(",");
+
+ // No filtering needed, everything displayed
+ if (filter_spec.default in types) {
+ return {}
+ }
+
+ const filter = {
+ binop : "OR", // TODO: support more complex queries
+ fields : {},
+ }
+
+ for (let i = 0; i < types.length; i++) {
+ const t = types[i];
+
+ if (!(t in filter_spec.types)) {
+ console.log("WARNING: unknown filter type " + t);
+ return {}
+ }
+
+ const constraint = filter_spec.types[t];
+
+
+ Object.getOwnPropertySymbols(constraint).forEach(key => {
+ assert(Object.keys(constraint) == 0 && typeof key == 'symbol',
+ "filter constraint keys MUST be symbolic table columns")
+ filter.fields[key] = constraint[key];
+ });
+ }
+
+ return filter;
+}
+
export async function fetch_user_middleware(req, res, next, id) {
id = parseInt(id);
diff --git a/app/components/PaginatedList.svelte b/app/components/PaginatedList.svelte
index db81dfa..9ee553f 100644
--- a/app/components/PaginatedList.svelte
+++ b/app/components/PaginatedList.svelte
@@ -11,8 +11,11 @@
let fetching = false;
let pagination = { page: 1 };
- export async function refresh() {
- await list_fetch(pagination.page)
+ export async function refresh(newpage) {
+ if (newpage !== undefined && typeof newpage == "number")
+ await list_fetch(newpage)
+ else
+ await list_fetch(pagination.page)
}
onMount(async () => {
diff --git a/app/views/Profile.svelte b/app/views/Profile.svelte
index 5bee3c8..c10970e 100644
--- a/app/views/Profile.svelte
+++ b/app/views/Profile.svelte
@@ -100,4 +100,4 @@
{/if}
-
+ refresh()} />
diff --git a/app/views/UserList.svelte b/app/views/UserList.svelte
index d7173b5..043db0a 100644
--- a/app/views/UserList.svelte
+++ b/app/views/UserList.svelte
@@ -1,18 +1,93 @@