An even more educational version of the Bandolier for Learn JS the Hard Way. https://learnjsthehardway.com/
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.
bandolier2/lib/ormish.js

623 lines
22 KiB

/*
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. If there's any situation where you can't do something in ORMish it's
expected that you will access the `knex` variable in thie module and simply use
knex directly. Here's how you'd do that:
```javascript
import { knex } from './lib/ormish.js';
// do your own update using a model's table name
knex(somemodel.table_name).where(where).update(what);
```
This makes it easy to use ORMish, but still break out when you need to create
custom queries or speed things up. The only advice here is to put any special
operations in a `class Model` subclass as a member function.
*/
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.
___BUG___: validator doesn't do proper date formatting yet, so it won't support date types.
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":
// 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);
}
/*
Returns the name of this model's current table. Used mostly internally but
useful otherwise. This is an attribute accessor (get) so you can just do:
```javascript
const name = obj.table_name;
```
Instead of calling it like a function. The main reason to use this is in
tools like the admin table browser found in `admin/pages/Table.svelte` and
`api/admin/table.js` so check those files out to see what's going on.
*/
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. It actually just
calls the `validation` module function with `validation(this.table_name, rules)`.
- `param rules {Object}` - rules specifier
*/
static validation(rules) {
return validation(this.table_name, rules);
}
/*
Delete this object from the database. We use the word "destroy" because
`delete` is reserved. It uses this object's `.id` to determine which object
to delete and uses this `knex` code:
```javascript
await knex(obj.table_name).where({id: obj.id}).del();
```
As with all of ORMish it doesn't handle any relations or references when it does
the delete so if you have constraints it will fail. Use `knex` directly in that
case.
+ `obj Model` -- the object to destroy, just have id
*/
static async destroy(obj) {
assert(this.table_name !== undefined, "You must set class variable table_name.");
assert(obj.id !== undefined, "No id in object to destroy.");
await knex(this.table_name).
where({id: obj.id}).
del();
}
/*
Does very basic 1-to-1 (1:1) relation, for use inside a custom function that returns
additional records. 1:1 mappings don't show up too often, as I think most modern database
designs would rather combine them into one giant table, but it does happen when you need to
add to a database without changing a "sacred" table.
This is really just a call to `model.first(where, columns)` and mostly acts as a kind of
documentation.
+ `model Model` - The other model to query.
+ `where Object` - The knex style selection criteria passed to `Model.first`.
+ `columns` - Passed to `Model.first` to restrict the columns returned.
+ ___return___ `Model` subclass found, or `undefined` if nothing.
*/
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);
}
/*
Maps this model to another with a 1-to-many (1:M) relational mapping. It queries
the other `model` based on the given { where }, which is usually an `id` in the other
table.
For example, if I have a `user` table and a `payment` table, I can have a 1 `user` -> M `payment`
like this:
```javascript
export class User extends Model.from_table('user') {
async payments() {
return await this.has_many(Payment, { user_id: this.id });
}
}
```
In this situation my `User` model is querying the `Payment` model for any `payment`
records that have `user_id=this.id`. That means if the `user.id` is 1, then it will
find any `payment` records with `user_id=1`.
*/
async has_many(model, where, columns) {
return await model.all(where, columns);
}
/*
Implements a simple many-to-many (M:M) using an intermediary table which
maps two table's IDs using two columns. For example, if you have a `User`
and `Payment` model, and you decide that `Payment` can have multiple users
then you'd do this:
```javascript
await paid1.many_to_many(User, "payment_user");
```
This is translated into `knex` as:
```javascript
await knex("user").where("id", "in",
knex("payment_user") // subquery in payment_user
.select(`user_id as id`)
.where(`payment_id`, "=", this.id)
);
```
The inverse operation would be:
```javascript
await user1.many_to_many(Payment, "payment_user");
```
Which is translated into `knex` as:
```javascript
await knex("payment").where("id", "in",
knex("payment_user") // subquery in payment_user
.select(`payment_id as id`)
.where(`user_id`, "=", this.id)
);
```
### Performance
This will fail if you have a massive many-to-many since it uses a subquery to get
a set of IDs, but in many cases it actually might outperform a more direct complicated
query. You should use this, then resort to raw `knex` code to craft a better one as
needed.
### Cleaning
Everything returned is first ran though `.clean()` to and turned into the
target model so you can use it directly. This ensures that if a database
driver infects the returned data with garbage it will be cleaned and you
get pure models. In the above example you'd get a `User` or `Payment`
with only what's in the schema.
### Attributed Relations
If your relation table has extra attributes--a super useful trick--then this
will not pick them up. In our example above, if you have another field
`payment_user.transaction_date` in addition to `user_id` and `payment_id` then
you won't get the `transaction_date`. In that case--you guessed it--use `knex`
directly to query for those.
*/
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;
}
/*
Counts the number of records matching the `where` specification.
It uses a direct SQL query using the `knex` operation `count`, but
`knex` returns a "database specific" result. This will try to
extract the count result, but will warn you when it can't do that.
+ `where Object` - knex where specification.
+ `columns Array` - columns to return
+ ___return___ `number`
*/
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
}
}
/*
Your generic insert for this model's table. The `attr` is checked against the
database schema by `knex`, so you have to remove anything that doesn't belong.
Use the `Model.clean` function to do that easily. Otherwise it works exactly
like in knex with:
```javascript
await knex(this.table_name).insert(attr);
```
This function expects a return value that has one result with the `id` of
the inserted row, which might be database specific. These days if a database
doesn't support this it's a trash database that shouldn't be used.
+ `attr Object` - The attributes to store.
+ ___return___ `number` - returns the id of the inserted object
*/
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).returning(['id']);
assert(res, `Failed to get an id from the insert for ${this.table_name}`);
attr.id = res[0].id;
return new this(attr);
}
/*
Implements an upsert (insert but update on conflict) for Postgres, MySQL, and SQLite3 only.
___TODO___: allow specifying returns for databases that support it
+ 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 if ignored, no update
*/
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;
if(merge) {
result = await knex(this.table_name)
.insert(attr)
.returning(['id'])
.onConflict(conflict_key)
.merge();
} else {
result = await knex(this.table_name).insert(attr).returning(['id']).onConflict(conflict_key).ignore();
}
// BUG: is returning undefined for ignored and no result right?
if(result == undefined || result.length == 0) {
return undefined;
} else {
attr.id = result[0].id;
return attr;
}
}
/*
Performs an UPDATE operation on the object `what` found by `where`. This
translates into the following `knex.js` code:
```javascript
return knex(this.table_name).where(where).update(what);
```
So it's mostly just syntactic sugar over `knex.js` (like everything in ORMish). If you
need a more complex `update` then just use `knex()` directly.
+ `where Object` -- The knex where options for a query.
+ `what Model` -- The table/Model to use as the update.
+ ___return___ `number` -- _WARNING_: I believe this returns the count updated but that might be database specific.
*/
static async update(where, what) {
assert(where, "You must give a where options.");
return knex(this.table_name).where(where).update(what);
}
/*
Returns one record from the table based on the `knex` where specification,
and can limit the columns (attributes) that are returned. This is a `static`
method so you use it like this:
```javascript
const person = User.first({id: 1});
```
The `where` can be anything that `knex` understands as query as well.
+ `where Object` -- The `knex` where specification.
+ `columns Array` -- List of columns to include in the returned object.
+ ___return___ `Object`
*/
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;
}
/*
Delete a given record--or batch of records--based on the `where` specification.
It really just calls this `knex` operation:
```javascript
return knex(this.table_name).where(where).del();
```
+ `where Object` -- The where specification, doesn't have to be an Object.
+ ___return___ `number` -- Number deleted, but that might be database specific.
*/
static async delete(where) {
assert(where, "You must give a where options.");
return knex(this.table_name).where(where).del();
}
/*
Returns all records matching the `where` specification, and also
reduces the returned columns based on the `columns` list. This is
the most common operation in `knex`, and when you do both `columns`
and `where` it's just doing this:
```javascript
results = await knex(this.table_name).column(columns).where(where).select();
```
As usual, if this isn't efficient or complex enough for you then you can
just do it directly in `knex`.
+ `where Object` -- The usual `knex` where specification, and can be anything `knex` likes.
+ `columns Array` -- The list of columns to return.
+ ___return___ `Array` of `Model` subclass.
*/
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;
}
/*
This is how you create your own models based on `Model`. It's a neat
trick as well, which allows you to specify a table to use to populate
a new class. First, you use it like this:
```javascript
class User extends Model.from_table('user') {
}
```
How this works:
+ `from_table` crafts an empty class with `let m = class extends Model {}`.
+ Since JavaScript is a scripting language you can modify this class, and return it.
+ `from_table` then adds a `table_name` and the `schema` to this class.
+ After that it returns the new empy class, which you then extend and now you have a class pre-configured with the schema and table_name already set.
Obviously this only works with ES6 style classes. After this setup you just add your own
methods, use `super` like normal, and everything else. The functions in `Model` will all
work because `schema` and `table_name` are set.
+ `table_name` -- the name of the base table in the database to use. Must be in `SCHEMA`.
*/
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;
}
/*
Determines if at least one record exists for the `where` specification.
This is doing a select for only an `id` column with a limit of 1, and if
it gets a result then it returns true. Probably not the most efficient
but it is portable. Here's what `knex` it's doing:
```javascript
let res = await knex(this.table_name).select('id').where(where).limit(1).first();
```
+ `where Object` -- the query specification for `knex`.
+ ___return___ `boolean` -- Whether it exists or not.
*/
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, validation };