You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
284 lines
9.2 KiB
284 lines
9.2 KiB
import config from '../knexfile.cjs';
|
|
import knexConfig from 'knex';
|
|
import assert from 'assert';
|
|
import { attachPaginate } from 'knex-paginate';
|
|
|
|
export const knex = knexConfig(config.development);
|
|
|
|
// run the PERF_TRICKS to configure sqlite3 when thing start, really need to make this
|
|
// a configuration and only do it with sqlite3, but for now just get this done
|
|
if(config.development.client === "sqlite3") {
|
|
const PERF_TRICKS = [
|
|
"pragma journal_mode = WAL", // use a WAL journal to not block writers/readers
|
|
"pragma synchronous = normal", // set concurrency to normal now that we have WAL
|
|
"pragma temp_store = memory", // use RAM to make the temporary indices
|
|
// "pragma mmap_size = 30000000000", // use 30GB of mmap to store DB, not useful in multi-process settings
|
|
// "pragma page_size = 32768", // improve performance only if you store large stuff
|
|
"pragma vacuum", // give it a vacuum, this could impact startup time significantly
|
|
"pragma optimize", // optimize it, but should be done periodically
|
|
];
|
|
|
|
for(let sql of PERF_TRICKS) {
|
|
await knex.raw(sql);
|
|
}
|
|
}
|
|
|
|
attachPaginate();
|
|
|
|
export const SCHEMA = {};
|
|
|
|
const load_schema = async () => {
|
|
const raw = await knex("sqlite_schema").where({"type": "table"}).select(['type', 'name']);
|
|
for(let table of raw) {
|
|
table._columns = await knex(table.name).columnInfo();
|
|
SCHEMA[table.name] = table;
|
|
}
|
|
}
|
|
|
|
await load_schema();
|
|
|
|
/**
|
|
* In some cases (like the generic admin) you need to get validation
|
|
* rules but you don't have a specific class to work with. This function
|
|
* is called by Model.validation and you can call it directly to get rules
|
|
* for a database table.
|
|
*
|
|
* @param name string - the table name.
|
|
* @param rules Object - default rules with empty "" for the rules you want filled in
|
|
* @param all boolean - set this to true if you want everything
|
|
* @param no_id boolean - defaults to true, set false if you also want the id
|
|
* @return Object - the resulting rules to use with Validator
|
|
*/
|
|
export const validation = (name, rules, all=false, no_id=true) => {
|
|
assert(rules, "rules parameter is required and will be modified");
|
|
const schema = SCHEMA[name];
|
|
assert(schema, `There is no schema for table named ${name}. Did you forget to migrate:latest?`);
|
|
|
|
for(let [key, opts] of Object.entries(schema._columns)) {
|
|
// most of the time you don't want the id
|
|
if(no_id && key === "id") continue;
|
|
|
|
if(all || rules[key] === "") {
|
|
let required = opts.nullable || opts.defaultValue ? "nullable|" : "required|";
|
|
|
|
switch(opts.type) {
|
|
case "varchar": // fallthrough
|
|
case "text":
|
|
// some databases have an email type but we'll also look for ones named email
|
|
if(key === "email") {
|
|
rules[key] = required + "email";
|
|
} else {
|
|
rules[key] = required + "string";
|
|
}
|
|
|
|
if(opts.maxLength) {
|
|
rules[key] += `|min:0|max:${opts.maxLength}`;
|
|
}
|
|
break;
|
|
case "boolean":
|
|
rules[key] = required + "boolean";
|
|
break;
|
|
case "integer":
|
|
case "float":
|
|
rules[key] = required + "numeric";
|
|
break;
|
|
case "date":
|
|
// BUG: validator doesn't do proper date formatting yet
|
|
// rules[key] = required + "date";
|
|
break;
|
|
case "email":
|
|
// not all databases have this
|
|
rules[key] = required + "email";
|
|
break;
|
|
default:
|
|
rules[key] = required;
|
|
}
|
|
}
|
|
}
|
|
|
|
return rules;
|
|
}
|
|
|
|
export class Model {
|
|
constructor(attr) {
|
|
assert(attr, "Must give attributes.");
|
|
Object.assign(this, attr);
|
|
}
|
|
|
|
static from(attr, also_remove=undefined) {
|
|
return new this(this.clean(attr, also_remove));
|
|
}
|
|
|
|
get schema() {
|
|
return this.constructor.schema;
|
|
}
|
|
|
|
static clean(attr, also_remove=undefined) {
|
|
assert(attr, "Must give attributes to clean.");
|
|
|
|
let clean_entries = Object.entries(attr)
|
|
.filter(([k,v]) => k in this.schema);
|
|
|
|
if(also_remove) also_remove.forEach(k => delete clean_entries[k]);
|
|
|
|
return Object.fromEntries(clean_entries);
|
|
}
|
|
|
|
get table_name() {
|
|
return this.constructor.table_name;
|
|
}
|
|
|
|
/**
|
|
* Returns an object of basic rules meant for lib/api.js:validate
|
|
* based on what's in the database. It's meant to be an easy to
|
|
* pass in starter which you can augment. It expects a set of rules
|
|
* with keys you want configured. Any key that's set to an empty string ""
|
|
* will be filled in with a minimum rule to match the database schema.
|
|
*
|
|
* It's designed to be called once at the top of an api/ handler to get
|
|
* a basic set of rules. You could also run it to print out the rules then
|
|
* simply write the rules directly where you need them.
|
|
*
|
|
* @param rules {Object} - rules specifier
|
|
*/
|
|
static validation(rules) {
|
|
return validation(this.table_name, rules);
|
|
}
|
|
|
|
async destroy() {
|
|
assert(this.table_name !== undefined, "You must set class variable table_name.");
|
|
|
|
await knex(this.table_name).
|
|
where({id: this.id}).
|
|
del();
|
|
}
|
|
|
|
async has_one(model, where, columns) {
|
|
assert(where.id !== undefined, `where must at least have id for has_one ${model.table_name} you have ${JSON.stringify(where)}`);
|
|
return await model.first(where, columns);
|
|
}
|
|
|
|
async has_many(model, where, columns) {
|
|
return await model.all(where, columns);
|
|
}
|
|
|
|
async many_to_many(model, through_table) {
|
|
// SECURITY: doing string interpolation which might allow injecting SQL
|
|
let query = knex(model.table_name).where("id", "in",
|
|
knex(through_table).select(`${model.table_name}_id as id`).where(`${this.table_name}_id`, "=", this.id));
|
|
|
|
let rows = await query;
|
|
|
|
let results = [];
|
|
|
|
for(let r of rows) {
|
|
results.push(new model(await model.clean(r)));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
static async count(where, columns) {
|
|
// the knex count api returns a DB specific result, so we need
|
|
// to specify what we want, which is count:
|
|
const spec = { count: columns || ['id']};
|
|
let res = await knex(this.table_name).where(where).count(spec);
|
|
if(res.length == 1) {
|
|
// single result, just give the count
|
|
return res[0].count;
|
|
} else {
|
|
console.warn("Your call to count in", this.table_name, "using where=", where, "columns: ", columns, "returned a weird result:", res);
|
|
return res; // weird result let them deal with it
|
|
}
|
|
}
|
|
|
|
static async insert(attr) {
|
|
assert(this.table_name !== undefined, "You must set class variable table_name.");
|
|
assert(attr, `You must give some attr to insert into ${this.table_name}`);
|
|
|
|
let res = await knex(this.table_name).insert(attr);
|
|
assert(res, `Failed to get an id from the insert for ${this.table_name}`);
|
|
|
|
attr.id = res[0];
|
|
|
|
return new this(attr);
|
|
}
|
|
|
|
/**
|
|
* Implements an upsert (insert but update on conflict) for Postgres, MySQL, and SQLite3 only.
|
|
*
|
|
* @param { Object } attr - The attributes to insert or update.
|
|
* @param { string } conflict_key - The key that can cause a conflict then update.
|
|
* @param { boolean } merge - Defaults to true and will change the record. false will ignore and not update on conflict.
|
|
* @return { number } - id or undefined
|
|
*/
|
|
static async upsert(attr, conflict_key, merge=true) {
|
|
assert(conflict_key !== undefined, `You forgot to set the conflict_key on upsert to table ${this.table_name}`);
|
|
let result = undefined;
|
|
|
|
// TODO: allow specifying returns for databases that support it
|
|
if(merge) {
|
|
result = await knex(this.table_name).insert(attr).onConflict(conflict_key).merge();
|
|
} else {
|
|
result = await knex(this.table_name).insert(attr).onConflict(conflict_key).ignore();
|
|
}
|
|
|
|
// returns the id of the row or undefined
|
|
return result !== undefined ? result[0] : undefined;
|
|
}
|
|
|
|
static async update(where, what) {
|
|
assert(where, "You must give a where options.");
|
|
return knex(this.table_name).where(where).update(what);
|
|
}
|
|
|
|
static async first(where, columns) {
|
|
assert(where, "You must give a where options.");
|
|
let attr = undefined;
|
|
|
|
if(columns) {
|
|
attr = await knex(this.table_name).column(columns).first().where(where);
|
|
} else {
|
|
attr = await knex(this.table_name).first().where(where);
|
|
}
|
|
|
|
return attr !== undefined ? new this(attr) : attr;
|
|
}
|
|
|
|
static async delete(where) {
|
|
assert(where, "You must give a where options.");
|
|
return knex(this.table_name).where(where).del();
|
|
}
|
|
|
|
static async all(where, columns) {
|
|
assert(where, "You must give a where options.");
|
|
let results = [];
|
|
|
|
if(columns) {
|
|
results = await knex(this.table_name).column(columns).where(where).select();
|
|
} else {
|
|
results = await knex(this.table_name).where(where);
|
|
}
|
|
|
|
let final = results.map(r => new this(r));
|
|
return final;
|
|
}
|
|
|
|
static from_table(table_name) {
|
|
let m = class extends Model { };
|
|
m.table_name = table_name;
|
|
|
|
assert(SCHEMA, "schema is not loaded!");
|
|
assert(SCHEMA[table_name], `table named ${table_name} not in SCHEMA: ${ Object.keys(SCHEMA) }`);
|
|
|
|
m.schema = SCHEMA[table_name]._columns;
|
|
return m;
|
|
}
|
|
|
|
static async exists(where) {
|
|
let res = await knex(this.table_name).select('id').where(where).limit(1).first();
|
|
return res ? res.id : false;
|
|
}
|
|
}
|
|
|
|
export default { knex, SCHEMA, Model };
|
|
|