From bf4275c5639a11d6c6047287eaefd6eb3f64a8c6 Mon Sep 17 00:00:00 2001 From: Tyler Neal Date: Tue, 5 Dec 2023 11:47:18 -0600 Subject: [PATCH 1/7] Update interfaces --- src/decorators/ColumnTypeOptions.ts | 6 ++++++ src/metadata/ColumnTypeMetadata.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/decorators/ColumnTypeOptions.ts b/src/decorators/ColumnTypeOptions.ts index a609ef6..ff07eb9 100644 --- a/src/decorators/ColumnTypeOptions.ts +++ b/src/decorators/ColumnTypeOptions.ts @@ -13,4 +13,10 @@ export interface ColumnTypeOptions extends ColumnBaseOptions { * Array of possible enumerated values */ enum?: string[]; + /** + * If set, enforces a maximum length check on the column + * + * Applies to types: string | string[] + */ + maxLength?: number; } diff --git a/src/metadata/ColumnTypeMetadata.ts b/src/metadata/ColumnTypeMetadata.ts index 82bb0ec..886403e 100644 --- a/src/metadata/ColumnTypeMetadata.ts +++ b/src/metadata/ColumnTypeMetadata.ts @@ -14,6 +14,12 @@ export interface ColumnTypeMetadataOptions extends ColumnBaseMetadataOptions { * Array of possible enumerated values */ enum?: string[]; + /** + * If set, enforces a maximum length check on the column + * + * Applies to types: string | string[] + */ + maxLength?: number; } export class ColumnTypeMetadata extends ColumnBaseMetadata { @@ -32,6 +38,13 @@ export class ColumnTypeMetadata extends ColumnBaseMetadata { */ public enum?: string[]; + /** + * If set, enforces a maximum length check on the column + * + * Applies to types: string | string[] + */ + public maxLength?: number; + public constructor(options: ColumnTypeMetadataOptions) { super({ target: options.target, @@ -49,5 +62,6 @@ export class ColumnTypeMetadata extends ColumnBaseMetadata { this.type = options.type; this.defaultsTo = options.defaultsTo; this.enum = options.enum; + this.maxLength = options.maxLength; } } From ffc0a96091152c8f615193ed857ec2c254e256b2 Mon Sep 17 00:00:00 2001 From: Tyler Neal Date: Tue, 5 Dec 2023 11:47:42 -0600 Subject: [PATCH 2/7] Add maxLength to ColumnTypeMetadata push --- src/decorators/column.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/decorators/column.ts b/src/decorators/column.ts index 5049e43..4edc683 100644 --- a/src/decorators/column.ts +++ b/src/decorators/column.ts @@ -91,6 +91,7 @@ export function column(dbColumnNameOrOptions?: ColumnOptions | string, options?: type: columnTypeOptions.type, defaultsTo: columnTypeOptions.defaultsTo, enum: columnTypeOptions.enum, + maxLength: columnTypeOptions.maxLength, }), ); }; From e1437cc72a6eda705c7cbb62527509615f7ee789 Mon Sep 17 00:00:00 2001 From: Tyler Neal Date: Tue, 5 Dec 2023 11:50:52 -0600 Subject: [PATCH 3/7] Update SqlHelper with enforce logic --- src/SqlHelper.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/SqlHelper.ts b/src/SqlHelper.ts index 2705bbd..dd69f0f 100644 --- a/src/SqlHelper.ts +++ b/src/SqlHelper.ts @@ -214,6 +214,20 @@ export function getInsertQueryAndParams]; + const normalizedValues = (Array.isArray(entityValues) ? entityValues : [entityValues]) as string[]; + + for (const normalizedValue of normalizedValues) { + if (normalizedValue.length > maxLength) { + throw new QueryError(`Create statement for "${model.name}" contains a value that exceeds maxLength on field: ${column.propertyName}`, model); + } + } + } } } @@ -434,6 +448,20 @@ export function getUpdateQueryAndParams({ } else { const isJsonArray = (column as ColumnTypeMetadata).type === 'json' && _.isArray(value); const relatedModelName = (column as ColumnModelMetadata).model; + + // Check and enforce max length for applicable types + const { maxLength, type } = column as ColumnTypeMetadata; + + if (maxLength && ['string', 'string[]'].includes(type)) { + const normalizedValues = (Array.isArray(value) ? value : [value]) as string[]; + + for (const normalizedValue of normalizedValues) { + if (normalizedValue.length > maxLength) { + throw new QueryError(`Create statement for "${model.name}" contains a value that exceeds maxLength on field: ${column.propertyName}`, model); + } + } + } + if (relatedModelName && _.isObject(value)) { const relatedModelRepository = repositoriesByModelNameLowered[relatedModelName.toLowerCase()]; From 15554f68af1335ff63418ddb2b05639448263f54 Mon Sep 17 00:00:00 2001 From: Tyler Neal Date: Tue, 5 Dec 2023 11:52:10 -0600 Subject: [PATCH 4/7] Create test model --- tests/models/ImportedItem.ts | 39 ++++++++++++++++++++++++++++++++++++ tests/models/index.ts | 3 ++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/models/ImportedItem.ts diff --git a/tests/models/ImportedItem.ts b/tests/models/ImportedItem.ts new file mode 100644 index 0000000..c187e88 --- /dev/null +++ b/tests/models/ImportedItem.ts @@ -0,0 +1,39 @@ +import { column, primaryColumn, table, Entity } from '../../src'; + +@table({ + name: 'imported_item', +}) +export class ImportedItem extends Entity { + @primaryColumn({ type: 'string' }) + public id!: string; + + @column({ + type: 'string', + required: true, + name: 'name', + }) + public name!: string; + + @column({ + type: 'string', + required: false, + name: 'external_id_no_max_length', + }) + public externalIdNoMaxLength?: string; + + @column({ + type: 'string', + required: false, + name: 'external_id_string', + maxLength: 5, + }) + public externalIdString?: string; + + @column({ + type: 'string[]', + required: false, + name: 'external_id_string_array', + maxLength: 10, + }) + public externalIdStringArray?: string[]; +} diff --git a/tests/models/index.ts b/tests/models/index.ts index 788e9b4..b8b68ee 100644 --- a/tests/models/index.ts +++ b/tests/models/index.ts @@ -1,9 +1,10 @@ export * from './Category'; export * from './Classroom'; +export * from './ImportedItem'; export * from './KitchenSink'; export * from './LevelOne'; -export * from './LevelTwo'; export * from './LevelThree'; +export * from './LevelTwo'; export * from './ParkingLot'; export * from './ParkingSpace'; export * from './Product'; From 300a7399fa91a57b077237cdf9ab98faceeb127a Mon Sep 17 00:00:00 2001 From: Tyler Neal Date: Tue, 5 Dec 2023 11:52:46 -0600 Subject: [PATCH 5/7] Insert maxLength tests --- tests/sqlHelper.tests.ts | 135 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 131 insertions(+), 4 deletions(-) diff --git a/tests/sqlHelper.tests.ts b/tests/sqlHelper.tests.ts index efafbb4..1d6dc07 100644 --- a/tests/sqlHelper.tests.ts +++ b/tests/sqlHelper.tests.ts @@ -12,26 +12,28 @@ import * as sqlHelper from '../src/SqlHelper'; import { Category, + ImportedItem, + KitchenSink, Product, ProductCategory, - ProductWithCreateUpdateDateTracking, ProductWithCreatedAt, + ProductWithCreateUpdateDateTracking, ReadonlyProduct, - Store, - KitchenSink, RequiredPropertyWithDefaultValue, RequiredPropertyWithDefaultValueFunction, SimpleWithCollections, SimpleWithCreatedAt, SimpleWithCreatedAtAndUpdatedAt, SimpleWithJson, - SimpleWithUpdatedAt, SimpleWithStringId, + SimpleWithUpdatedAt, SimpleWithVersion, + Store, } from './models'; interface RepositoriesByModelName { Category: IRepository; + ImportedItem: IRepository; KitchenSink: IRepository; Product: IRepository; ProductCategory: IRepository; @@ -65,6 +67,7 @@ describe('sqlHelper', () => { repositoriesByModelName = initialize({ models: [ Category, + ImportedItem, KitchenSink, Product, ProductCategory, @@ -843,6 +846,130 @@ describe('sqlHelper', () => { }); }); }); + describe('maxLength', () => { + it('Should allow insert when maxLength set but column not required AND value not set', () => { + const itemId = faker.number.int(); + const itemName = faker.string.uuid(); + + const { query, params } = sqlHelper.getInsertQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + values: [ + { + id: itemId, + name: itemName, + }, + ], + returnRecords: false, + }); + + query.should.equal(`INSERT INTO "${repositoriesByModelNameLowered.importeditem.model.tableName}" ("id","name") VALUES ($1,$2)`); + params.should.deep.equal([itemId, itemName]); + }); + it('Should not enforce maxLength when not set for column', () => { + const itemId = faker.number.int(); + const itemName = faker.string.uuid(); + const externalId = 'a'.repeat(1000); + + const { query, params } = sqlHelper.getInsertQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + values: [ + { + id: itemId, + name: itemName, + externalIdNoMaxLength: externalId, + }, + ], + returnRecords: false, + }); + + query.should.equal(`INSERT INTO "${repositoriesByModelNameLowered.importeditem.model.tableName}" ("id","name","external_id_no_max_length") VALUES ($1,$2,$3)`); + params.should.deep.equal([itemId, itemName, externalId]); + }); + it('Should allow insert (string) when under maxLength', () => { + const itemId = faker.number.int(); + const itemName = faker.string.uuid(); + const externalId = 'a'.repeat(5); + + const { query, params } = sqlHelper.getInsertQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + values: [ + { + id: itemId, + name: itemName, + externalIdString: externalId, + }, + ], + returnRecords: false, + }); + + query.should.equal(`INSERT INTO "${repositoriesByModelNameLowered.importeditem.model.tableName}" ("id","name","external_id_string") VALUES ($1,$2,$3)`); + params.should.deep.equal([itemId, itemName, externalId]); + }); + it('Should throw error on insert (string) when above maxLength', () => { + const itemId = faker.number.int(); + const itemName = faker.string.uuid(); + const externalId = 'a'.repeat(6); + + ((): void => { + sqlHelper.getInsertQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + values: [ + { + id: itemId, + name: itemName, + externalIdString: externalId, + }, + ], + returnRecords: false, + }); + }).should.throw(QueryError, `Create statement for "${repositoriesByModelNameLowered.importeditem.model.name}" contains a value that exceeds maxLength on field: externalIdString`); + }); + it('Should allow insert (string[]) when under maxLength', () => { + const itemId = faker.number.int(); + const itemName = faker.string.uuid(); + const externalIds = ['a'.repeat(10), 'b'.repeat(10)]; + + const { query, params } = sqlHelper.getInsertQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + values: [ + { + id: itemId, + name: itemName, + externalIdStringArray: externalIds, + }, + ], + returnRecords: false, + }); + + query.should.equal(`INSERT INTO "${repositoriesByModelNameLowered.importeditem.model.tableName}" ("id","name","external_id_string_array") VALUES ($1,$2,$3)`); + params.should.deep.equal([itemId, itemName, externalIds]); + }); + it('Should throw error on insert (string[]) when above maxLength', () => { + const itemId = faker.number.int(); + const itemName = faker.string.uuid(); + const externalIds = ['a'.repeat(11), 'b'.repeat(10)]; + + ((): void => { + sqlHelper.getInsertQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + values: [ + { + id: itemId, + name: itemName, + externalIdStringArray: externalIds, + }, + ], + returnRecords: false, + }); + }).should.throw(QueryError, `Create statement for "${repositoriesByModelNameLowered.importeditem.model.name}" contains a value that exceeds maxLength on field: externalIdString`); + }); + }); }); describe('#getUpdateQueryAndParams()', () => { it('should not set createdAt if schema.autoCreatedAt and value is undefined', () => { From a3ee501035152db76708c658fb626ad4637617ed Mon Sep 17 00:00:00 2001 From: Tyler Neal Date: Tue, 5 Dec 2023 12:03:28 -0600 Subject: [PATCH 6/7] Tests for on update --- src/SqlHelper.ts | 2 +- tests/sqlHelper.tests.ts | 114 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/SqlHelper.ts b/src/SqlHelper.ts index dd69f0f..7dc4c30 100644 --- a/src/SqlHelper.ts +++ b/src/SqlHelper.ts @@ -457,7 +457,7 @@ export function getUpdateQueryAndParams({ for (const normalizedValue of normalizedValues) { if (normalizedValue.length > maxLength) { - throw new QueryError(`Create statement for "${model.name}" contains a value that exceeds maxLength on field: ${column.propertyName}`, model); + throw new QueryError(`Update statement for "${model.name}" contains a value that exceeds maxLength on field: ${column.propertyName}`, model); } } } diff --git a/tests/sqlHelper.tests.ts b/tests/sqlHelper.tests.ts index 1d6dc07..a2dc545 100644 --- a/tests/sqlHelper.tests.ts +++ b/tests/sqlHelper.tests.ts @@ -1177,6 +1177,120 @@ describe('sqlHelper', () => { query.should.equal(`UPDATE "${repositoriesByModelNameLowered.product.model.tableName}" SET "name"=$1,"store_id"=$2 WHERE "id"=$3`); params.should.deep.equal([name, storeId, productId]); }); + describe('maxLength', () => { + it('Should allow update when maxLength set but column not required AND value not set', () => { + const itemId = faker.number.int(); + const itemName = faker.string.uuid(); + + const { query, params } = sqlHelper.getUpdateQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + where: { + id: itemId, + }, + values: { + name: itemName, + }, + returnRecords: false, + }); + + query.should.equal(`UPDATE "${repositoriesByModelNameLowered.importeditem.model.tableName}" SET "name"=$1 WHERE "id"=$2`); + params.should.deep.equal([itemName, itemId]); + }); + it('Should not enforce maxLength when not set for column', () => { + const itemId = faker.number.int(); + const externalId = 'a'.repeat(1000); + + const { query, params } = sqlHelper.getUpdateQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + where: { + id: itemId, + }, + values: { + externalIdNoMaxLength: externalId, + }, + returnRecords: false, + }); + + query.should.equal(`UPDATE "${repositoriesByModelNameLowered.importeditem.model.tableName}" SET "external_id_no_max_length"=$1 WHERE "id"=$2`); + params.should.deep.equal([externalId, itemId]); + }); + it('Should allow update (string) when under maxLength', () => { + const itemId = faker.number.int(); + const externalId = 'a'.repeat(5); + + const { query, params } = sqlHelper.getUpdateQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + where: { + id: itemId, + }, + values: { + externalIdString: externalId, + }, + returnRecords: false, + }); + + query.should.equal(`UPDATE "${repositoriesByModelNameLowered.importeditem.model.tableName}" SET "external_id_string"=$1 WHERE "id"=$2`); + params.should.deep.equal([externalId, itemId]); + }); + it('Should throw error on update (string) when above maxLength', () => { + const itemId = faker.number.int(); + const externalId = 'a'.repeat(6); + + ((): void => { + sqlHelper.getUpdateQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + where: { + id: itemId, + }, + values: { + externalIdString: externalId, + }, + returnRecords: false, + }); + }).should.throw(QueryError, `Update statement for "${repositoriesByModelNameLowered.importeditem.model.name}" contains a value that exceeds maxLength on field: externalIdString`); + }); + it('Should allow update (string[]) when under maxLength', () => { + const itemId = faker.number.int(); + const externalIds = ['a'.repeat(10), 'b'.repeat(10)]; + + const { query, params } = sqlHelper.getUpdateQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + where: { + id: itemId, + }, + values: { + externalIdStringArray: externalIds, + }, + returnRecords: false, + }); + + query.should.equal(`UPDATE "${repositoriesByModelNameLowered.importeditem.model.tableName}" SET "external_id_string_array"=$1 WHERE "id"=$2`); + params.should.deep.equal([externalIds, itemId]); + }); + it('Should throw error on update (string[]) when above maxLength', () => { + const itemId = faker.number.int(); + const externalIds = ['a'.repeat(11), 'b'.repeat(10)]; + + ((): void => { + sqlHelper.getUpdateQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + where: { + id: itemId, + }, + values: { + externalIdStringArray: externalIds, + }, + returnRecords: false, + }); + }).should.throw(QueryError, `Update statement for "${repositoriesByModelNameLowered.importeditem.model.name}" contains a value that exceeds maxLength on field: externalIdString`); + }); + }); }); describe('#getDeleteQueryAndParams()', () => { it('should delete all records if no where statement is defined', () => { From cb6afde18d05942ab844e2c6e8b8a5a20b4805ee Mon Sep 17 00:00:00 2001 From: Tyler Neal Date: Tue, 5 Dec 2023 12:09:19 -0600 Subject: [PATCH 7/7] Add test for unsupported column type --- tests/models/ImportedItem.ts | 8 ++++++++ tests/sqlHelper.tests.ts | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/tests/models/ImportedItem.ts b/tests/models/ImportedItem.ts index c187e88..fc8c768 100644 --- a/tests/models/ImportedItem.ts +++ b/tests/models/ImportedItem.ts @@ -36,4 +36,12 @@ export class ImportedItem extends Entity { maxLength: 10, }) public externalIdStringArray?: string[]; + + @column({ + type: 'integer', + required: false, + name: 'unrelated', + maxLength: 2, + }) + public unrelated?: number; } diff --git a/tests/sqlHelper.tests.ts b/tests/sqlHelper.tests.ts index a2dc545..40ac818 100644 --- a/tests/sqlHelper.tests.ts +++ b/tests/sqlHelper.tests.ts @@ -887,6 +887,27 @@ describe('sqlHelper', () => { query.should.equal(`INSERT INTO "${repositoriesByModelNameLowered.importeditem.model.tableName}" ("id","name","external_id_no_max_length") VALUES ($1,$2,$3)`); params.should.deep.equal([itemId, itemName, externalId]); }); + it('Should not enforce maxLength when set on unsupported column type', () => { + const itemId = faker.number.int(); + const itemName = faker.string.uuid(); + const unrelatedValue = 12345; // maxLength: 2 + + const { query, params } = sqlHelper.getInsertQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + values: [ + { + id: itemId, + name: itemName, + unrelated: unrelatedValue, + }, + ], + returnRecords: false, + }); + + query.should.equal(`INSERT INTO "${repositoriesByModelNameLowered.importeditem.model.tableName}" ("id","name","unrelated") VALUES ($1,$2,$3)`); + params.should.deep.equal([itemId, itemName, unrelatedValue]); + }); it('Should allow insert (string) when under maxLength', () => { const itemId = faker.number.int(); const itemName = faker.string.uuid(); @@ -1216,6 +1237,25 @@ describe('sqlHelper', () => { query.should.equal(`UPDATE "${repositoriesByModelNameLowered.importeditem.model.tableName}" SET "external_id_no_max_length"=$1 WHERE "id"=$2`); params.should.deep.equal([externalId, itemId]); }); + it('Should not enforce maxLength when set on unsupported column type', () => { + const itemId = faker.number.int(); + const unrelatedValue = 12345; // maxLength: 2 + + const { query, params } = sqlHelper.getUpdateQueryAndParams({ + repositoriesByModelNameLowered, + model: repositoriesByModelNameLowered.importeditem.model as ModelMetadata, + where: { + id: itemId, + }, + values: { + unrelated: unrelatedValue, + }, + returnRecords: false, + }); + + query.should.equal(`UPDATE "${repositoriesByModelNameLowered.importeditem.model.tableName}" SET "unrelated"=$1 WHERE "id"=$2`); + params.should.deep.equal([unrelatedValue, itemId]); + }); it('Should allow update (string) when under maxLength', () => { const itemId = faker.number.int(); const externalId = 'a'.repeat(5);