feat: limit database identifier (#908)

This commit is contained in:
ChengLei Shao 2022-10-14 14:51:19 +08:00 committed by chenos
parent 76f5754e20
commit eaea8a7100
14 changed files with 231 additions and 5 deletions

View File

@ -1,6 +1,7 @@
import { Collection } from '../collection'; import { Collection } from '../collection';
import { Database } from '../database'; import { Database } from '../database';
import { mockDatabase } from './index'; import { mockDatabase } from './index';
import { IdentifierError } from '../errors/identifier-error';
describe('collection', () => { describe('collection', () => {
let db: Database; let db: Database;
@ -258,4 +259,50 @@ describe('collection sync', () => {
expect(tableFields['postId']).toBeDefined(); expect(tableFields['postId']).toBeDefined();
expect(tableFields['tagId']).toBeDefined(); expect(tableFields['tagId']).toBeDefined();
}); });
test('limit table name length', async () => {
const longName =
'this_is_a_very_long_table_name_that_should_be_truncated_this_is_a_very_long_table_name_that_should_be_truncated';
let error;
try {
const collection = new Collection(
{
name: longName,
fields: [{ type: 'string', name: 'test' }],
},
{
database: db,
},
);
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(IdentifierError);
});
test('limit field name length', async () => {
const longFieldName =
'this_is_a_very_long_field_name_that_should_be_truncated_this_is_a_very_long_field_name_that_should_be_truncated';
let error;
try {
const collection = new Collection(
{
name: 'test',
fields: [{ type: 'string', name: longFieldName }],
},
{
database: db,
},
);
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(IdentifierError);
});
}); });

View File

@ -1,5 +1,6 @@
import { Database } from '../../database'; import { Database } from '../../database';
import { mockDatabase } from '../'; import { mockDatabase } from '../';
import { IdentifierError } from '../../errors/identifier-error';
describe('belongs to field', () => { describe('belongs to field', () => {
let db: Database; let db: Database;
@ -28,12 +29,16 @@ describe('belongs to field', () => {
{ type: 'belongsTo', name: 'post' }, { type: 'belongsTo', name: 'post' },
], ],
}); });
expect(Comment.model.associations.post).toBeUndefined(); expect(Comment.model.associations.post).toBeUndefined();
const Post = db.collection({ const Post = db.collection({
name: 'posts', name: 'posts',
fields: [{ type: 'string', name: 'title' }], fields: [{ type: 'string', name: 'title' }],
}); });
const association = Comment.model.associations.post; const association = Comment.model.associations.post;
expect(Comment.model.associations.post).toBeDefined(); expect(Comment.model.associations.post).toBeDefined();
expect(association.foreignKey).toBe('postId'); expect(association.foreignKey).toBe('postId');
// @ts-ignore // @ts-ignore
@ -77,11 +82,41 @@ describe('belongs to field', () => {
const association = Comment.model.associations.post; const association = Comment.model.associations.post;
expect(association).toBeDefined(); expect(association).toBeDefined();
expect(association.foreignKey).toBe('postKey'); expect(association.foreignKey).toBe('postKey');
// @ts-ignore // @ts-ignore
expect(association.targetKey).toBe('key'); expect(association.targetKey).toBe('key');
expect(Comment.model.rawAttributes['postKey']).toBeDefined(); expect(Comment.model.rawAttributes['postKey']).toBeDefined();
}); });
it('should throw error when foreignKey is too long', async () => {
const Post = db.collection({
name: 'posts',
fields: [{ type: 'string', name: 'key', unique: true }],
});
const longForeignKey = 'a'.repeat(128);
let error;
try {
const Comment = db.collection({
name: 'comments1',
fields: [
{
type: 'belongsTo',
name: 'post',
targetKey: 'key',
foreignKey: longForeignKey,
},
],
});
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(IdentifierError);
});
it('custom name and target', async () => { it('custom name and target', async () => {
const Comment = db.collection({ const Comment = db.collection({
name: 'comments', name: 'comments',

View File

@ -1,5 +1,6 @@
import { mockDatabase } from '../'; import { mockDatabase } from '../';
import { Database } from '../../database'; import { Database } from '../../database';
import { IdentifierError } from '../../errors/identifier-error';
describe('belongs to many field', () => { describe('belongs to many field', () => {
let db: Database; let db: Database;
@ -58,4 +59,48 @@ describe('belongs to many field', () => {
expect(PostTag.model.rawAttributes['postId']).toBeDefined(); expect(PostTag.model.rawAttributes['postId']).toBeDefined();
expect(PostTag.model.rawAttributes['tagId']).toBeDefined(); expect(PostTag.model.rawAttributes['tagId']).toBeDefined();
}); });
it('should throw error when foreignKey is too long', async () => {
const Post = db.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'tags', foreignKey: 'a'.repeat(128) },
],
});
let error;
try {
const Tag = db.collection({
name: 'tags',
fields: [{ type: 'string', name: 'name' }],
});
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(IdentifierError);
});
it('should throw error when through is too long', async () => {
const Post = db.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'name' },
{ type: 'belongsToMany', name: 'tags', through: 'a'.repeat(128) },
],
});
let error;
try {
const Tag = db.collection({
name: 'tags',
fields: [{ type: 'string', name: 'name' }],
});
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(IdentifierError);
});
}); });

View File

@ -1,6 +1,7 @@
import { Database } from '../../database'; import { Database } from '../../database';
import { mockDatabase } from '../'; import { mockDatabase } from '../';
import { makeWatchHost } from 'ts-loader/dist/servicesHost'; import { makeWatchHost } from 'ts-loader/dist/servicesHost';
import { IdentifierError } from '../../errors/identifier-error';
describe('has many field', () => { describe('has many field', () => {
let db: Database; let db: Database;
@ -149,4 +150,25 @@ describe('has many field', () => {
Comment.removeField('post'); Comment.removeField('post');
expect(Comment.model.rawAttributes.postId).toBeUndefined(); expect(Comment.model.rawAttributes.postId).toBeUndefined();
}); });
it('should throw error when foreignKey is too long', async () => {
const longForeignKey = 'a'.repeat(64);
const Post = db.collection({
name: 'posts',
fields: [{ type: 'hasMany', name: 'comments', foreignKey: longForeignKey }],
});
let error;
try {
const Comment = db.collection({
name: 'comments',
fields: [{ type: 'belongsTo', name: 'post' }],
});
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(IdentifierError);
});
}); });

View File

@ -1,5 +1,6 @@
import { Database } from '../../database'; import { Database } from '../../database';
import { mockDatabase } from '../'; import { mockDatabase } from '../';
import { IdentifierError } from '../../errors/identifier-error';
describe('has many field', () => { describe('has many field', () => {
let db: Database; let db: Database;
@ -64,4 +65,24 @@ describe('has many field', () => {
Profile.removeField('user'); Profile.removeField('user');
expect(Profile.model.rawAttributes.userId).toBeUndefined(); expect(Profile.model.rawAttributes.userId).toBeUndefined();
}); });
it('should throw error when foreignKey is too long', async () => {
const longForeignKey = 'a'.repeat(128);
const User = db.collection({
name: 'users',
fields: [{ type: 'hasOne', name: 'profile', foreignKey: longForeignKey }],
});
let error;
try {
const Profile = db.collection({
name: 'profiles',
fields: [{ type: 'belongsTo', name: 'user' }],
});
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(IdentifierError);
});
}); });

View File

@ -7,13 +7,13 @@ import {
QueryInterfaceDropTableOptions, QueryInterfaceDropTableOptions,
SyncOptions, SyncOptions,
Transactionable, Transactionable,
Utils Utils,
} from 'sequelize'; } from 'sequelize';
import { Database } from './database'; import { Database } from './database';
import { Field, FieldOptions } from './fields'; import { Field, FieldOptions } from './fields';
import { Model } from './model'; import { Model } from './model';
import { Repository } from './repository'; import { Repository } from './repository';
import { md5 } from './utils'; import { checkIdentifier, md5 } from './utils';
export type RepositoryType = typeof Repository; export type RepositoryType = typeof Repository;
@ -67,8 +67,11 @@ export class Collection<
constructor(options: CollectionOptions, context?: CollectionContext) { constructor(options: CollectionOptions, context?: CollectionContext) {
super(); super();
this.checkOptions(options);
this.context = context; this.context = context;
this.options = options; this.options = options;
this.bindFieldEventListener(); this.bindFieldEventListener();
this.modelInit(); this.modelInit();
this.setFields(options.fields); this.setFields(options.fields);
@ -76,6 +79,10 @@ export class Collection<
this.setSortable(options.sortable); this.setSortable(options.sortable);
} }
private checkOptions(options: CollectionOptions) {
checkIdentifier(options.name);
}
private sequelizeModelOptions() { private sequelizeModelOptions() {
const { name, tableName } = this.options; const { name, tableName } = this.options;
return { return {
@ -160,6 +167,8 @@ export class Collection<
} }
setField(name: string, options: FieldOptions): Field { setField(name: string, options: FieldOptions): Field {
checkIdentifier(name);
const { database } = this.context; const { database } = this.context;
const field = database.buildField( const field = database.buildField(
@ -169,6 +178,7 @@ export class Collection<
collection: this, collection: this,
}, },
); );
this.removeField(name); this.removeField(name);
this.fields.set(name, field); this.fields.set(name, field);
this.emit('field.afterAdd', field); this.emit('field.afterAdd', field);

View File

@ -14,7 +14,7 @@ import {
Sequelize, Sequelize,
SyncOptions, SyncOptions,
Transactionable, Transactionable,
Utils Utils,
} from 'sequelize'; } from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug'; import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection'; import { Collection, CollectionOptions, RepositoryType } from './collection';
@ -361,9 +361,11 @@ export class Database extends EventEmitter implements AsyncEmitter {
buildField(options, context: FieldContext) { buildField(options, context: FieldContext) {
const { type } = options; const { type } = options;
const Field = this.fieldTypes.get(type); const Field = this.fieldTypes.get(type);
if (!Field) { if (!Field) {
throw Error(`unsupported field type ${type}`); throw Error(`unsupported field type ${type}`);
} }
return new Field(options, context); return new Field(options, context);
} }

View File

@ -0,0 +1,6 @@
export class IdentifierError extends Error {
constructor(message: string) {
super(message);
this.name = 'IdentifierError';
}
}

View File

@ -1,6 +1,7 @@
import { omit } from 'lodash'; import { omit } from 'lodash';
import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';
import { checkIdentifier } from '../utils';
export class BelongsToField extends RelationField { export class BelongsToField extends RelationField {
static type = 'belongsTo'; static type = 'belongsTo';
@ -42,10 +43,13 @@ export class BelongsToField extends RelationField {
this.options.foreignKey = association.foreignKey; this.options.foreignKey = association.foreignKey;
} }
checkIdentifier(this.options.foreignKey);
if (!this.options.sourceKey) { if (!this.options.sourceKey) {
// @ts-ignore // @ts-ignore
this.options.sourceKey = association.sourceKey; this.options.sourceKey = association.sourceKey;
} }
this.collection.addIndex([this.options.foreignKey]); this.collection.addIndex([this.options.foreignKey]);
return true; return true;
} }

View File

@ -2,6 +2,7 @@ import { omit } from 'lodash';
import { BelongsToManyOptions as SequelizeBelongsToManyOptions, Utils } from 'sequelize'; import { BelongsToManyOptions as SequelizeBelongsToManyOptions, Utils } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { MultipleRelationFieldOptions, RelationField } from './relation-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field';
import { checkIdentifier } from '../utils';
export class BelongsToManyField extends RelationField { export class BelongsToManyField extends RelationField {
get through() { get through() {
@ -23,6 +24,7 @@ export class BelongsToManyField extends RelationField {
database.addPendingField(this); database.addPendingField(this);
return false; return false;
} }
const through = this.through; const through = this.through;
let Through: Collection; let Through: Collection;
@ -33,6 +35,7 @@ export class BelongsToManyField extends RelationField {
Through = database.collection({ Through = database.collection({
name: through, name: through,
}); });
Object.defineProperty(Through.model, 'isThrough', { value: true }); Object.defineProperty(Through.model, 'isThrough', { value: true });
} }
@ -49,15 +52,23 @@ export class BelongsToManyField extends RelationField {
if (!this.options.foreignKey) { if (!this.options.foreignKey) {
this.options.foreignKey = association.foreignKey; this.options.foreignKey = association.foreignKey;
} }
checkIdentifier(this.options.foreignKey);
if (!this.options.sourceKey) { if (!this.options.sourceKey) {
this.options.sourceKey = association.sourceKey; this.options.sourceKey = association.sourceKey;
} }
if (!this.options.otherKey) { if (!this.options.otherKey) {
this.options.otherKey = association.otherKey; this.options.otherKey = association.otherKey;
} }
checkIdentifier(this.options.otherKey);
if (!this.options.through) { if (!this.options.through) {
this.options.through = this.through; this.options.through = this.through;
} }
Through.addIndex([this.options.foreignKey]); Through.addIndex([this.options.foreignKey]);
Through.addIndex([this.options.otherKey]); Through.addIndex([this.options.otherKey]);
return true; return true;

View File

@ -9,6 +9,7 @@ import {
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { Database } from '../database'; import { Database } from '../database';
import { checkIdentifier } from '../utils';
export interface FieldContext { export interface FieldContext {
database: Database; database: Database;

View File

@ -5,10 +5,11 @@ import {
ForeignKeyOptions, ForeignKeyOptions,
HasManyOptions, HasManyOptions,
HasManyOptions as SequelizeHasManyOptions, HasManyOptions as SequelizeHasManyOptions,
Utils Utils,
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { MultipleRelationFieldOptions, RelationField } from './relation-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field';
import { checkIdentifier } from '../utils';
export interface HasManyFieldOptions extends HasManyOptions { export interface HasManyFieldOptions extends HasManyOptions {
/** /**
@ -98,6 +99,7 @@ export class HasManyField extends RelationField {
as: this.name, as: this.name,
foreignKey: this.foreignKey, foreignKey: this.foreignKey,
}); });
// inverse relation // inverse relation
// this.TargetModel.belongsTo(collection.model); // this.TargetModel.belongsTo(collection.model);
@ -107,6 +109,9 @@ export class HasManyField extends RelationField {
if (!this.options.foreignKey) { if (!this.options.foreignKey) {
this.options.foreignKey = association.foreignKey; this.options.foreignKey = association.foreignKey;
} }
checkIdentifier(this.options.foreignKey);
if (!this.options.sourceKey) { if (!this.options.sourceKey) {
// @ts-ignore // @ts-ignore
this.options.sourceKey = association.sourceKey; this.options.sourceKey = association.sourceKey;

View File

@ -5,10 +5,11 @@ import {
ForeignKeyOptions, ForeignKeyOptions,
HasOneOptions, HasOneOptions,
HasOneOptions as SequelizeHasOneOptions, HasOneOptions as SequelizeHasOneOptions,
Utils Utils,
} from 'sequelize'; } from 'sequelize';
import { Collection } from '../collection'; import { Collection } from '../collection';
import { BaseRelationFieldOptions, RelationField } from './relation-field'; import { BaseRelationFieldOptions, RelationField } from './relation-field';
import { checkIdentifier } from '../utils';
export interface HasOneFieldOptions extends HasOneOptions { export interface HasOneFieldOptions extends HasOneOptions {
/** /**
@ -92,21 +93,28 @@ export class HasOneField extends RelationField {
database.addPendingField(this); database.addPendingField(this);
return false; return false;
} }
const association = collection.model.hasOne(Target, { const association = collection.model.hasOne(Target, {
constraints: false, constraints: false,
...omit(this.options, ['name', 'type', 'target']), ...omit(this.options, ['name', 'type', 'target']),
as: this.name, as: this.name,
foreignKey: this.foreignKey, foreignKey: this.foreignKey,
}); });
// 建立关系之后从 pending 列表中删除 // 建立关系之后从 pending 列表中删除
database.removePendingField(this); database.removePendingField(this);
if (!this.options.foreignKey) { if (!this.options.foreignKey) {
this.options.foreignKey = association.foreignKey; this.options.foreignKey = association.foreignKey;
} }
checkIdentifier(this.options.foreignKey);
if (!this.options.sourceKey) { if (!this.options.sourceKey) {
// @ts-ignore // @ts-ignore
this.options.sourceKey = association.sourceKey; this.options.sourceKey = association.sourceKey;
} }
let tcoll: Collection; let tcoll: Collection;
if (this.target === collection.name) { if (this.target === collection.name) {
tcoll = collection; tcoll = collection;

View File

@ -1,5 +1,6 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { Model } from './model'; import { Model } from './model';
import { IdentifierError } from './errors/identifier-error';
type HandleAppendsQueryOptions = { type HandleAppendsQueryOptions = {
templateModel: any; templateModel: any;
@ -49,3 +50,11 @@ export async function handleAppendsQuery(options: HandleAppendsQueryOptions) {
export function md5(value: string) { export function md5(value: string) {
return crypto.createHash('md5').update(value).digest('hex'); return crypto.createHash('md5').update(value).digest('hex');
} }
const MAX_IDENTIFIER_LENGTH = 63;
export function checkIdentifier(value: string) {
if (value.length > MAX_IDENTIFIER_LENGTH) {
throw new IdentifierError(`Identifier ${value} is too long`);
}
}