You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

306 lines
9.0 KiB
JavaScript

"use strict";
const assert = require("assert");
const matchValue = require("match-value");
const dbErrors = require("db-errors");
const DataLoader = require("dataloader");
const knexIntrospect = require("./introspect");
const dataloaderFromKnexResults = require("./dataloader-from-knex-results");
// TODO: Full verification that Knex doesn't allow for SQL injection anywhere dynamic values are specified below
/* Future ideas:
- Update record: give each type an update method for that individual record, by ID? For "get previous value, then set new value" style usecases
*/
async function wrapErrors(callback) {
try {
return callback();
} catch (error) {
throw dbErrors.wrapError(error);
}
}
function applyFilterPredicate(query, column, predicate, negate = false) {
if (predicate.__comparator === "not") {
return applyFilterPredicate(query, column, predicate.comparator, true);
} else if (predicate.__comparator === "anyOf") {
if (negate) {
return query.whereNotIn(column, predicate.value);
} else {
return query.whereIn(column, predicate.value);
}
} else {
let operator = matchValue(predicate.__comparator, {
equals: "=",
moreThan: ">",
lessThan: "<"
});
if (predicate.value === null) {
assert(operator === "=");
// Only for explicit nulls!
if (negate) {
return query.whereNotNull(column);
} else {
return query.whereNull(column);
}
} else {
if (negate) {
return query.whereNot(column, operator, predicate.value);
} else {
return query.where(column, operator, predicate.value);
}
}
}
}
function applyFilterArgument(query, filter) {
for (let [ column, predicate ] of Object.entries(filter)) {
if (predicate == null || predicate.__comparator == null) {
query = applyFilterPredicate(query, column, { __comparator: "equals", value: predicate });
} else {
query = applyFilterPredicate(query, column, predicate);
}
}
return query;
}
module.exports = {
lessThan: function (value) {
return {
__comparator: "lessThan",
value: value
};
},
moreThan: function (value) {
return {
__comparator: "moreThan",
value: value
};
},
anyOf: function (values) {
return {
__comparator: "anyOf",
value: values
};
},
not: function (comparator) {
return {
__comparator: "not",
comparator: comparator
};
},
generateModule: async function generate(globalKnex, tableName, relations = {}) {
// Make sure we only look up each table once, even when resolving relations
let introspectLoader = new DataLoader((tables) => {
return Promise.all(tables.map((table) => knexIntrospect(globalKnex, table)));
});
// relations: { columnName: [ belongsTo, hasMany ] }, eg. on a posts table, it might have { thread_id: [ "thread", "posts" ] }
let tableLayout = await introspectLoader.load(tableName);
let fieldNames = Object.keys(tableLayout);
let primaryKeyColumn = Object.values(tableLayout).find((props) => props.isPrimaryKey === true);
let primaryKey = primaryKeyColumn?.name;
assert(primaryKey != null);
// note that we cannot namespace by database name, because eg. sqlite databases don't *have* a guaranteed-unique name. using multiple databases is an uncommon case anyway, and if that is ever needed, we can add a namespace config option for those specific cases
let typeName = `dlayer-knex.${tableName}`;
let remoteTypes = {};
Object.keys(relations).map((idFieldName) => {
let remoteField = relations[idFieldName][1]; // eg. "posts"
let foreignKey = tableLayout[idFieldName].foreignKey;
let remoteTypeName = `dlayer-knex.${foreignKey.table}`;
if (remoteTypes[remoteTypeName] == null) {
remoteTypes[remoteTypeName] = {};
}
remoteTypes[remoteTypeName][remoteField] = async function(_, { $make, $getProperty, knex }) {
let tx = (knex ?? globalKnex);
let result = await wrapErrors(async () => {
return tx(tableName).columns("id").where({
[idFieldName]: await $getProperty(this, primaryKey)
});
});
return result.map((item) => {
return $make(typeName, { id: item.id });
});
};
});
return {
typeName: typeName,
module: {
name: `knex.${tableName}`,
makeContext: (globalContext) => {
let { knex } = globalContext;
let tx = (knex ?? globalKnex);
return {
dlayerKnexTable: dataloaderFromKnexResults({
fetch: (ids) => tx(tableName).whereIn(primaryKey, ids),
selectKey: (record) => record[primaryKey]
})
};
},
types: {
[typeName]: async function({ id }) {
let normalFields = fieldNames.map((name) => {
return [ name, async (_, { dlayerKnexTable }) => {
let record = await dlayerKnexTable.load(id);
return record[name];
}];
});
let relationFields = [];
for (let name of Object.keys(relations)) {
let localField = relations[name][0]; // eg. "thread"
let foreignKey = tableLayout[name].foreignKey;
let remoteTableLayout = await introspectLoader.load(foreignKey.table);
if (remoteTableLayout[foreignKey.column].isPrimaryKey === true) {
let field = [ localField, async function (_, { $make, $getProperty }) {
return $make(`dlayer-knex.${foreignKey.table}`, { id: await $getProperty(this, name) })
}];
relationFields.push(field);
} else {
// TODO: Throw error instead?
console.warn(`Foreign key points at column ${foreignKey.table}.${foreignKey.column}, but that column is not a primary key, and this is not supported in dlayer-knex yet`);
}
}
return Object.fromEntries([
... normalFields,
... relationFields
]);
// determine primary key field, use that as the ID attribute? then use dataloader for fetching different items, and use the same dataloader instance as a cache
}
},
extensions: remoteTypes,
root: {
// NOTE: We don't specify any root stuff here; instead we return rootQueries as a property from the call, and let the caller worry about where in the tree to put these queries (eg. it might be as part of another module)
}
},
rootQueries: {
// top-level stuff? but expect the developer to place that in a specific location
list: async function ({ filter, orderBy, skip, limit, first }, { $make, knex }) {
let tx = (knex ?? globalKnex);
let query = tx(tableName).column(primaryKey);
if (filter != null) {
query = applyFilterArgument(query, filter);
}
if (orderBy != null) {
let ascending = !orderBy.startsWith("-");
let orderField = (ascending === true)
? orderBy
: orderBy.slice(1);
query = query.orderBy(orderField, (ascending === true) ? "asc" : "desc");
}
if (skip != null) {
query = query.offset(skip);
}
if (limit != null) {
query = query.limit(limit);
}
let result = await wrapErrors(async () => {
return query;
});
// TODO: Map result(s) to types
if (first === true) {
if (result.length > 0) {
return $make(typeName, { id: result[0].id });
} else {
return null;
}
} else {
return result.map((item) => {
return $make(typeName, { id: item.id });
});
}
},
delete: async function ({ filter }, { knex }) {
assert(filter != null);
let tx = (knex ?? globalKnex);
let query = tx(tableName).delete();
query = applyFilterArgument(query, filter);
let deletedRowCount = await wrapErrors(async () => {
return query;
});
return { count: deletedRowCount };
},
change: async function ({ filter, values }, { $make, $getModuleContext, knex }) {
assert(values != null);
let tx = (knex ?? globalKnex);
// NOTE: This is a workaround; since we pass these root queries to the developer to place in the tree themselves, they technically don't execute from within the generate module, and therefore would not normally have access to its context... so we access that context by module name here
let { dlayerKnexTable } = $getModuleContext(`knex.${tableName}`);
let query = tx(tableName)
.update(values)
.returning("id");
if (filter != null) {
applyFilterArgument(query, filter);
}
let affected = await wrapErrors(async () => {
return query;
});
return affected.map((item) => {
dlayerKnexTable.clear(item.id); // yeet from dataloader cache, so that we fetch the updated version instead
return $make(typeName, { id: item.id });
});
},
create: async function ({ values }, { $make, knex }) {
assert(values != null);
let tx = (knex ?? globalKnex);
let isArray = Array.isArray(values);
let query = tx(tableName)
.insert(values)
.returning("id");
let result = await wrapErrors(async () => {
return query;
});
if (isArray) {
return result.map((item) => {
return $make(typeName, { id: item.id });
});
} else {
return $make(typeName, { id: result[0].id })
}
}
}
};
}
};