/ *
The ORMish object - relational - mapping - ish library for knex . It ' s just enough ORM added to knex to be useful , and anything else you need can be done with knex .
* /
import config from '../knexfile.cjs' ;
import knexConfig from 'knex' ;
import assert from 'assert' ;
import { attachPaginate } from 'knex-paginate' ;
/ *
A preconfigured knex . js driver using the config . development configuration
by default .
_TODO _ : Need to make this configurable , even though I just use one config right now since I run sqlite3 all the time .
* /
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 ( ) ;
/ *
Filled in by ` load_schema ` to give access to the database scheme in the admin
tool and generally through the API .
* /
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 .
1. ` name string ` - the table name .
2. ` rules Object ` - default rules with empty "" for the rules you want filled in
3. ` all boolean ` - set this to true if you want everything
4. ` no_id boolean ` - defaults to true , set false if you also want the id
5. ` 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 ;
}
/ *
The base class for all models found in ` lib/models.js ` . You use this by extending it with :
` ` ` javascript
class User extends Model . from _table ( 'user' ) {
}
` ` `
This will create a ` User ` class that is automatically configured using the SCHEMA create from the ` user ` table in your database . You won ' t need to define the attributes on this class as it will be correctly populated from the database .
The database is therefore the "source of truth" for all of the models . You can then add functions to extend what this class does .
* /
export class Model {
/ *
Allows you to build a new object of this Model with the given ` attr `
already set , but really you should use the ` Model.from(attr) ` method instead .
This does _no _ object sanitization with ` Model.clean(attr) ` method , and if
it doesn ' t match the underlying database it will throw an exception .
- ` attr Object ` - the attributes for this model
* /
constructor ( attr ) {
assert ( attr , "Must give attributes." ) ;
Object . assign ( this , attr ) ;
}
/ *
How to actually create a new instance of this model . This
will do two things :
1. Correctly use the schema for the subclass model .
2. Sanitize the input to remove anything that shouldn ' t be in the database .
The ` also_remove ` parameter is a list of additional keys to also scrub from the object .
- ` attr Object ` -- The attributes this should start with .
- ` also_remove Array ` -- list of additional attributes to remove .
* /
static from ( attr , also _remove = undefined ) {
return new this ( this . clean ( attr , also _remove ) ) ;
}
/ *
Returns an object representing the schema for this Model . Remember that this
will reflect what ' s in the database schema , which is formatted however
` knex.js ` formats your database Schema . Might not be portable between
databases and only tested with SQlite3 .
_This is an attribute accessor , so just do ` obj.schema ` rather than call it like a function . _
- ` return Object ` - The schema for this model .
* /
get schema ( ) {
return this . constructor . schema ;
}
/ *
Uses the ` this.schema ` scrub out any attributes that are not valid for the
schema . This is effectively a whitelist for the allowed attributes based on
the database schema . You can use the ` also_remove ` parameter to list
additional attributes to remove , which you should do to sanitize incoming
objects for things like password fields .
- ` attr Object ` - The attributes to clean .
- ` also_remove Array ` - Additional attributes to remove .
* /
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 .
+ attr { Object } - The attributes to insert or update .
+ conflict _key { string } - The key that can cause a conflict then update .
+ merge { boolean } - 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 } ;