From a7697f2982226c6e6fab68c5a8c1cfcd7b370fa7 Mon Sep 17 00:00:00 2001 From: Dirk de Visser Date: Thu, 5 Nov 2020 21:55:35 +0100 Subject: [PATCH] code-gen: add field checks to partials when on staging This works by dumping the possible keys, and looping over the input objects. It is guarded by an `isStaging()` check, so shouldn't be too much of a performance hit. Closes #466 --- .../src/generator/sql/partial-type.js | 4 + .../src/generator/sql/query-partials.js | 55 +++- .../code-gen/src/generator/sql/where-type.js | 13 + packages/code-gen/test/sql.test.js | 35 ++- packages/stdlib/src/error.js | 8 + .../store/src/generated/query-partials.js | 274 ++++++++++++++++++ 6 files changed, 387 insertions(+), 2 deletions(-) diff --git a/packages/code-gen/src/generator/sql/partial-type.js b/packages/code-gen/src/generator/sql/partial-type.js index dc50a24402..e3152f1188 100644 --- a/packages/code-gen/src/generator/sql/partial-type.js +++ b/packages/code-gen/src/generator/sql/partial-type.js @@ -111,6 +111,8 @@ export function getInsertPartial(context, type) { for (let i = 0; i < insert.length; ++i) { const it = insert[i]; + checkFieldsInSet("${type.name}", "insert", ${type.name}FieldSet, it); + q.append(query\`( $\{options?.includePrimaryKey ? query\`$\{it.${primaryKey}}, \` : undefined} $\{${type.partial.fields @@ -182,6 +184,8 @@ export function getUpdatePartial(context, type) { const strings = []; const values = []; + checkFieldsInSet("${type.name}", "update", ${type.name}FieldSet, update); + ${partials} // Remove the comma suffix strings[0] = strings[0].substring(2); diff --git a/packages/code-gen/src/generator/sql/query-partials.js b/packages/code-gen/src/generator/sql/query-partials.js index 1b0a14f264..5f5a6be2ed 100644 --- a/packages/code-gen/src/generator/sql/query-partials.js +++ b/packages/code-gen/src/generator/sql/query-partials.js @@ -5,7 +5,7 @@ import { getQueryEnabledObjects, getSortedKeysForType, } from "./utils.js"; -import { getWherePartial } from "./where-type.js"; +import { getWhereFieldSet, getWherePartial } from "./where-type.js"; /** * Generate all usefull query partials @@ -15,6 +15,16 @@ import { getWherePartial } from "./where-type.js"; export function generateQueryPartials(context) { const partials = []; + // Generate field sets and the check function + partials.push(knownFieldsCheckFunction()); + for (const type of getQueryEnabledObjects(context)) { + partials.push(getWhereFieldSet(context, type)); + if (!type.queryOptions.isView) { + partials.push(getFieldSet(context, type)); + } + } + + // Generate the query partials for (const type of getQueryEnabledObjects(context)) { partials.push(getFieldsPartial(context, type)); partials.push(getWherePartial(context, type)); @@ -26,6 +36,7 @@ export function generateQueryPartials(context) { } const file = js` + import { AppError, isStaging } from "@lbu/stdlib"; import { query, isQueryObject } from "@lbu/store"; ${partials} @@ -40,6 +51,48 @@ export function generateQueryPartials(context) { ); } +/** + * Static field in set check function + * + * @returns {string} + */ +export function knownFieldsCheckFunction() { + // We create a copy of the Set & convert to array before throwing, in case someone + // tries to mutate it. We can also safely skip 'undefined' values, since they will + // never be used in queries. + return js` + /** + * + * @param {string} entity + * @param {string} subType + * @param {Set} set + * @param {*} value + */ + function checkFieldsInSet(entity, subType, set, value) { + if (isStaging()) { + for (const key of Object.keys(value)) { + if (!set.has(key) && value[key] !== undefined) { + throw new AppError(\`query.$\{entity}.$\{subType}Fields\`, 500, { + unknownKey: key, knownKeys: [ ...set ], + }); + } + } + } + } + `; +} + +/** + * + * @param {CodeGenContext} context + * @param {CodeGenObjectType} type + */ +export function getFieldSet(context, type) { + return `const ${type.name}FieldSet = new Set(["${Object.keys(type.keys).join( + `", "`, + )}"]);`; +} + /** * A list of fields for the provided type, with dynamic tableName * @property {CodeGenContext} context diff --git a/packages/code-gen/src/generator/sql/where-type.js b/packages/code-gen/src/generator/sql/where-type.js index 128393fade..3a607d6eb6 100644 --- a/packages/code-gen/src/generator/sql/where-type.js +++ b/packages/code-gen/src/generator/sql/where-type.js @@ -90,6 +90,17 @@ export function createWhereTypes(context) { } } +/** + * + * @param {CodeGenContext} context + * @param {CodeGenObjectType} type + */ +export function getWhereFieldSet(context, type) { + return `const ${type.name}WhereFieldSet = new Set(["${type.where.fields + .map((it) => it.name) + .join(`", "`)}"]);`; +} + /** * * @param {CodeGenContext} context @@ -229,6 +240,8 @@ export function getWherePartial(context, type) { tableName = \`$\{tableName}.\`; } + checkFieldsInSet("${type.name}", "where", ${type.name}WhereFieldSet, where); + const strings = [ "1 = 1" ]; const values = [ undefined ]; diff --git a/packages/code-gen/test/sql.test.js b/packages/code-gen/test/sql.test.js index bd05cfe66f..3542a23e8b 100644 --- a/packages/code-gen/test/sql.test.js +++ b/packages/code-gen/test/sql.test.js @@ -1,5 +1,5 @@ import { mainTestFn, test } from "@lbu/cli"; -import { isNil, uuid } from "@lbu/stdlib"; +import { AppError, isNil, uuid } from "@lbu/stdlib"; import { cleanupTestPostgresDatabase, createTestPostgresDatabase, @@ -154,6 +154,39 @@ test("code-gen/e2e/sql", async (t) => { t.equal(postCount, 0, "soft cascading deletes"); }); + t.test("unknown key 'where'", async (t) => { + try { + await client.queries.userSelect(sql, { foo: "bar" }); + t.fail("Should throw with AppError, based on checkFields function."); + } catch (e) { + t.ok(AppError.instanceOf(e)); + t.equal(e.key, `query.user.whereFields`); + t.equal(e.info.unknownKey, "foo"); + } + }); + + t.test("unknown key 'update'", async (t) => { + try { + await client.queries.postUpdate(sql, { baz: true }, { foo: "bar" }); + t.fail("Should throw with AppError, based on checkFields function."); + } catch (e) { + t.ok(AppError.instanceOf(e)); + t.equal(e.key, `query.post.updateFields`); + t.equal(e.info.unknownKey, "baz"); + } + }); + + t.test("unknown key 'insert'", async (t) => { + try { + await client.queries.categoryInsert(sql, { quix: 6 }); + t.fail("Should throw with AppError, based on checkFields function."); + } catch (e) { + t.ok(AppError.instanceOf(e)); + t.equal(e.key, `query.category.insertFields`); + t.equal(e.info.unknownKey, "quix"); + } + }); + t.test("destroy test db", async (t) => { await cleanupTestPostgresDatabase(sql); t.ok(true); diff --git a/packages/stdlib/src/error.js b/packages/stdlib/src/error.js index 73d90eb424..9a821acc21 100644 --- a/packages/stdlib/src/error.js +++ b/packages/stdlib/src/error.js @@ -130,4 +130,12 @@ export class AppError extends Error { [inspect.custom]() { return AppError.format(this); } + + /** + * Use AppError#format when AppError is passed to JSON.stringify(). + * This is used in the lbu insight logger in production mode. + */ + toJSON() { + return AppError.format(this); + } } diff --git a/packages/store/src/generated/query-partials.js b/packages/store/src/generated/query-partials.js index 53c9f3078c..fc8c4541e7 100644 --- a/packages/store/src/generated/query-partials.js +++ b/packages/store/src/generated/query-partials.js @@ -1,7 +1,268 @@ // Generated by @lbu/code-gen /* eslint-disable no-unused-vars */ +import { AppError, isStaging } from "@lbu/stdlib"; import { query, isQueryObject } from "@lbu/store"; +/** + * @param {string} entity + * @param {string} subType + * @param {Set} set + * @param {*} value + */ +function checkFieldsInSet(entity, subType, set, value) { + if (isStaging()) { + for (const key of Object.keys(value)) { + if (!set.has(key) && value[key] !== undefined) { + throw new AppError(`query.${entity}.${subType}Fields`, 500, { + unknownKey: key, + knownKeys: [...set], + }); + } + } + } +} +const fileWhereFieldSet = new Set([ + "id", + "idNotEqual", + "idIn", + "idNotIn", + "idLike", + "idNotLike", + "bucketName", + "bucketNameNotEqual", + "bucketNameIn", + "bucketNameNotIn", + "bucketNameLike", + "bucketNameNotLike", + "createdAt", + "createdAtNotEqual", + "createdAtIn", + "createdAtNotIn", + "createdAtGreaterThan", + "createdAtLowerThan", + "createdAtIsNull", + "createdAtIsNotNull", + "updatedAt", + "updatedAtNotEqual", + "updatedAtIn", + "updatedAtNotIn", + "updatedAtGreaterThan", + "updatedAtLowerThan", + "updatedAtIsNull", + "updatedAtIsNotNull", + "deletedAt", + "deletedAtNotEqual", + "deletedAtIn", + "deletedAtNotIn", + "deletedAtGreaterThan", + "deletedAtLowerThan", + "deletedAtIncludeNotNull", +]); +const fileFieldSet = new Set([ + "bucketName", + "contentLength", + "contentType", + "name", + "meta", + "id", + "createdAt", + "updatedAt", + "deletedAt", +]); +const fileGroupWhereFieldSet = new Set([ + "id", + "idNotEqual", + "idIn", + "idNotIn", + "idLike", + "idNotLike", + "file", + "fileNotEqual", + "fileIn", + "fileNotIn", + "fileLike", + "fileNotLike", + "fileIsNull", + "fileIsNotNull", + "parent", + "parentNotEqual", + "parentIn", + "parentNotIn", + "parentLike", + "parentNotLike", + "parentIsNull", + "parentIsNotNull", + "createdAt", + "createdAtNotEqual", + "createdAtIn", + "createdAtNotIn", + "createdAtGreaterThan", + "createdAtLowerThan", + "createdAtIsNull", + "createdAtIsNotNull", + "updatedAt", + "updatedAtNotEqual", + "updatedAtIn", + "updatedAtNotIn", + "updatedAtGreaterThan", + "updatedAtLowerThan", + "updatedAtIsNull", + "updatedAtIsNotNull", + "deletedAt", + "deletedAtNotEqual", + "deletedAtIn", + "deletedAtNotIn", + "deletedAtGreaterThan", + "deletedAtLowerThan", + "deletedAtIncludeNotNull", +]); +const fileGroupFieldSet = new Set([ + "name", + "order", + "meta", + "id", + "file", + "parent", + "createdAt", + "updatedAt", + "deletedAt", +]); +const fileGroupViewWhereFieldSet = new Set([ + "id", + "idNotEqual", + "idIn", + "idNotIn", + "idLike", + "idNotLike", + "isDirectory", + "file", + "fileNotEqual", + "fileIn", + "fileNotIn", + "fileLike", + "fileNotLike", + "fileIsNull", + "fileIsNotNull", + "parent", + "parentNotEqual", + "parentIn", + "parentNotIn", + "parentLike", + "parentNotLike", + "parentIsNull", + "parentIsNotNull", + "createdAt", + "createdAtNotEqual", + "createdAtIn", + "createdAtNotIn", + "createdAtGreaterThan", + "createdAtLowerThan", + "createdAtIsNull", + "createdAtIsNotNull", + "updatedAt", + "updatedAtNotEqual", + "updatedAtIn", + "updatedAtNotIn", + "updatedAtGreaterThan", + "updatedAtLowerThan", + "updatedAtIsNull", + "updatedAtIsNotNull", + "deletedAt", + "deletedAtNotEqual", + "deletedAtIn", + "deletedAtNotIn", + "deletedAtGreaterThan", + "deletedAtLowerThan", + "deletedAtIncludeNotNull", +]); +const jobWhereFieldSet = new Set([ + "id", + "idNotEqual", + "idIn", + "idNotIn", + "idGreaterThan", + "idLowerThan", + "isComplete", + "isCompleteIsNull", + "isCompleteIsNotNull", + "name", + "nameNotEqual", + "nameIn", + "nameNotIn", + "nameLike", + "nameNotLike", + "scheduledAt", + "scheduledAtNotEqual", + "scheduledAtIn", + "scheduledAtNotIn", + "scheduledAtGreaterThan", + "scheduledAtLowerThan", + "scheduledAtIsNull", + "scheduledAtIsNotNull", + "createdAt", + "createdAtNotEqual", + "createdAtIn", + "createdAtNotIn", + "createdAtGreaterThan", + "createdAtLowerThan", + "createdAtIsNull", + "createdAtIsNotNull", + "updatedAt", + "updatedAtNotEqual", + "updatedAtIn", + "updatedAtNotIn", + "updatedAtGreaterThan", + "updatedAtLowerThan", + "updatedAtIsNull", + "updatedAtIsNotNull", +]); +const jobFieldSet = new Set([ + "id", + "isComplete", + "priority", + "scheduledAt", + "name", + "data", + "createdAt", + "updatedAt", +]); +const sessionWhereFieldSet = new Set([ + "id", + "idNotEqual", + "idIn", + "idNotIn", + "idLike", + "idNotLike", + "expires", + "expiresNotEqual", + "expiresIn", + "expiresNotIn", + "expiresGreaterThan", + "expiresLowerThan", + "createdAt", + "createdAtNotEqual", + "createdAtIn", + "createdAtNotIn", + "createdAtGreaterThan", + "createdAtLowerThan", + "createdAtIsNull", + "createdAtIsNotNull", + "updatedAt", + "updatedAtNotEqual", + "updatedAtIn", + "updatedAtNotIn", + "updatedAtGreaterThan", + "updatedAtLowerThan", + "updatedAtIsNull", + "updatedAtIsNotNull", +]); +const sessionFieldSet = new Set([ + "expires", + "data", + "id", + "createdAt", + "updatedAt", +]); /** * Get all fields for file * @param {string} [tableName="f."] @@ -31,6 +292,7 @@ export function fileWhere(where = {}, tableName = "f.") { if (tableName.length > 0 && !tableName.endsWith(".")) { tableName = `${tableName}.`; } + checkFieldsInSet("file", "where", fileWhereFieldSet, where); const strings = ["1 = 1"]; const values = [undefined]; if (where.id !== undefined) { @@ -357,6 +619,7 @@ export function fileInsertValues(insert, options = {}) { const q = query``; for (let i = 0; i < insert.length; ++i) { const it = insert[i]; + checkFieldsInSet("file", "insert", fileFieldSet, it); q.append(query`( ${options?.includePrimaryKey ? query`${it.id}, ` : undefined} ${it.contentLength ?? null}, ${it.bucketName ?? null}, ${ @@ -379,6 +642,7 @@ ${it.contentLength ?? null}, ${it.bucketName ?? null}, ${ export function fileUpdateSet(update) { const strings = []; const values = []; + checkFieldsInSet("file", "update", fileFieldSet, update); if (update.contentLength !== undefined) { strings.push(`, "contentLength" = `); values.push(update.contentLength ?? null); @@ -443,6 +707,7 @@ export function fileGroupWhere(where = {}, tableName = "fg.") { if (tableName.length > 0 && !tableName.endsWith(".")) { tableName = `${tableName}.`; } + checkFieldsInSet("fileGroup", "where", fileGroupWhereFieldSet, where); const strings = ["1 = 1"]; const values = [undefined]; if (where.id !== undefined) { @@ -832,6 +1097,7 @@ export function fileGroupInsertValues(insert, options = {}) { const q = query``; for (let i = 0; i < insert.length; ++i) { const it = insert[i]; + checkFieldsInSet("fileGroup", "insert", fileGroupFieldSet, it); q.append(query`( ${options?.includePrimaryKey ? query`${it.id}, ` : undefined} ${it.order ?? Math.floor(Date.now() / 1000000)}, ${it.file ?? null}, ${ @@ -854,6 +1120,7 @@ ${it.order ?? Math.floor(Date.now() / 1000000)}, ${it.file ?? null}, ${ export function fileGroupUpdateSet(update) { const strings = []; const values = []; + checkFieldsInSet("fileGroup", "update", fileGroupFieldSet, update); if (update.order !== undefined) { strings.push(`, "order" = `); values.push(update.order ?? Math.floor(Date.now() / 1000000)); @@ -918,6 +1185,7 @@ export function fileGroupViewWhere(where = {}, tableName = "fgv.") { if (tableName.length > 0 && !tableName.endsWith(".")) { tableName = `${tableName}.`; } + checkFieldsInSet("fileGroupView", "where", fileGroupViewWhereFieldSet, where); const strings = ["1 = 1"]; const values = [undefined]; if (where.id !== undefined) { @@ -1327,6 +1595,7 @@ export function jobWhere(where = {}, tableName = "j.") { if (tableName.length > 0 && !tableName.endsWith(".")) { tableName = `${tableName}.`; } + checkFieldsInSet("job", "where", jobWhereFieldSet, where); const strings = ["1 = 1"]; const values = [undefined]; if (where.id !== undefined) { @@ -1663,6 +1932,7 @@ export function jobInsertValues(insert, options = {}) { const q = query``; for (let i = 0; i < insert.length; ++i) { const it = insert[i]; + checkFieldsInSet("job", "insert", jobFieldSet, it); q.append(query`( ${options?.includePrimaryKey ? query`${it.id}, ` : undefined} ${it.isComplete ?? false}, ${it.priority ?? 0}, ${it.name ?? null}, ${ @@ -1685,6 +1955,7 @@ ${it.isComplete ?? false}, ${it.priority ?? 0}, ${it.name ?? null}, ${ export function jobUpdateSet(update) { const strings = []; const values = []; + checkFieldsInSet("job", "update", jobFieldSet, update); if (update.isComplete !== undefined) { strings.push(`, "isComplete" = `); values.push(update.isComplete ?? false); @@ -1745,6 +2016,7 @@ export function sessionWhere(where = {}, tableName = "s.") { if (tableName.length > 0 && !tableName.endsWith(".")) { tableName = `${tableName}.`; } + checkFieldsInSet("session", "where", sessionWhereFieldSet, where); const strings = ["1 = 1"]; const values = [undefined]; if (where.id !== undefined) { @@ -2008,6 +2280,7 @@ export function sessionInsertValues(insert, options = {}) { const q = query``; for (let i = 0; i < insert.length; ++i) { const it = insert[i]; + checkFieldsInSet("session", "insert", sessionFieldSet, it); q.append(query`( ${options?.includePrimaryKey ? query`${it.id}, ` : undefined} ${it.expires ?? null}, ${JSON.stringify(it.data ?? {})}, ${ @@ -2028,6 +2301,7 @@ ${it.expires ?? null}, ${JSON.stringify(it.data ?? {})}, ${ export function sessionUpdateSet(update) { const strings = []; const values = []; + checkFieldsInSet("session", "update", sessionFieldSet, update); if (update.expires !== undefined) { strings.push(`, "expires" = `); values.push(update.expires ?? null);