|
| 1 | +import { |
| 2 | + AnyObject, |
| 3 | + Count, |
| 4 | + DataObject, |
| 5 | + DefaultCrudRepository, |
| 6 | + Entity, |
| 7 | + EntityNotFoundError, |
| 8 | + Fields, |
| 9 | + Filter, |
| 10 | + FilterExcludingWhere, |
| 11 | + Operators, |
| 12 | + PropertyDefinition, |
| 13 | + Where, |
| 14 | +} from '@loopback/repository'; |
| 15 | +import { |
| 16 | + Attributes, |
| 17 | + DataType, |
| 18 | + DataTypes, |
| 19 | + FindAttributeOptions, |
| 20 | + Identifier, |
| 21 | + Model, |
| 22 | + ModelAttributeColumnOptions, |
| 23 | + ModelAttributes, |
| 24 | + ModelStatic, |
| 25 | + Op, |
| 26 | + Order, |
| 27 | + WhereOptions, |
| 28 | +} from 'sequelize'; |
| 29 | +import {MakeNullishOptional} from 'sequelize/types/utils'; |
| 30 | +import {SequelizeDataSource} from './sequelize.datasource.base'; |
| 31 | + |
| 32 | +class SequelizeModel extends Model implements Entity { |
| 33 | + getId() { |
| 34 | + return null; |
| 35 | + } |
| 36 | + getIdObject(): Object { |
| 37 | + return {}; |
| 38 | + } |
| 39 | + toObject(options?: AnyObject | undefined): Object { |
| 40 | + return {}; |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +const isTruelyObject = (value?: unknown) => { |
| 45 | + return typeof value === 'object' && !Array.isArray(value) && value !== null; |
| 46 | +}; |
| 47 | +/** |
| 48 | + * @key Operator used in loopback |
| 49 | + * @value Equivalent operator in Sequelize |
| 50 | + */ |
| 51 | +const operatorTranslations: { |
| 52 | + [key in Operators]?: symbol; |
| 53 | +} = { |
| 54 | + eq: Op.eq, |
| 55 | + gt: Op.gt, |
| 56 | + gte: Op.gte, |
| 57 | + lt: Op.lt, |
| 58 | + lte: Op.lte, |
| 59 | + neq: Op.ne, |
| 60 | + between: Op.between, |
| 61 | + inq: Op.in, |
| 62 | + nin: Op.notIn, |
| 63 | + like: Op.like, |
| 64 | + nlike: Op.notLike, |
| 65 | + ilike: Op.iLike, |
| 66 | + nilike: Op.notILike, |
| 67 | + regexp: Op.regexp, |
| 68 | + and: Op.and, |
| 69 | + or: Op.or, |
| 70 | +}; |
| 71 | + |
| 72 | +export class SequelizeRepository< |
| 73 | + T extends Entity, |
| 74 | + ID, |
| 75 | + Relations extends object = {}, |
| 76 | +> extends DefaultCrudRepository<T, ID, Relations> { |
| 77 | + sequelizeModel: ModelStatic<Model<T>>; |
| 78 | + constructor( |
| 79 | + public entityClass: typeof Entity & { |
| 80 | + prototype: T; |
| 81 | + }, |
| 82 | + public dataSource: SequelizeDataSource, |
| 83 | + ) { |
| 84 | + super(entityClass, dataSource); |
| 85 | + console.log('Juggler entity definition', entityClass.definition.properties); |
| 86 | + |
| 87 | + if (this.dataSource.sequelize) { |
| 88 | + this.sequelizeModel = this.getSequelizeModel(); |
| 89 | + } |
| 90 | + console.log('this.sequelizeModel', this.sequelizeModel); |
| 91 | + } |
| 92 | + |
| 93 | + async create( |
| 94 | + entity: MakeNullishOptional<T>, |
| 95 | + options?: AnyObject, |
| 96 | + ): Promise<T> { |
| 97 | + console.log('Create entity', entity); |
| 98 | + const data = await this.sequelizeModel.create(entity, options); |
| 99 | + return data.toJSON(); |
| 100 | + } |
| 101 | + |
| 102 | + // updateById isn't implemented saperately because the existing one internally |
| 103 | + // calls updateAll method which is handled below |
| 104 | + |
| 105 | + async updateAll( |
| 106 | + data: DataObject<T>, |
| 107 | + where?: Where<T> | undefined, |
| 108 | + options?: AnyObject | undefined, |
| 109 | + ): Promise<Count> { |
| 110 | + const [affectedCount] = await this.sequelizeModel.update( |
| 111 | + Object.assign({} as AnyObject, data), |
| 112 | + { |
| 113 | + where: where ? this.buildSequelizeWhere(where) : {}, |
| 114 | + ...options, |
| 115 | + }, |
| 116 | + ); |
| 117 | + return {count: affectedCount}; |
| 118 | + } |
| 119 | + |
| 120 | + async find( |
| 121 | + filter?: Filter<T>, |
| 122 | + options?: AnyObject, |
| 123 | + ): Promise<(T & Relations)[]> { |
| 124 | + const data = await this.sequelizeModel.findAll({ |
| 125 | + ...(filter?.fields |
| 126 | + ? {attributes: this.buildSequelizeAttributeFilter(filter.fields)} |
| 127 | + : {}), |
| 128 | + ...(filter?.where ? {where: this.buildSequelizeWhere(filter.where)} : {}), |
| 129 | + ...(filter?.order ? {order: this.buildSequelizeOrder(filter.order)} : {}), |
| 130 | + ...(filter?.limit ? {limit: filter.limit} : {}), |
| 131 | + ...(filter?.offset || filter?.skip |
| 132 | + ? {offset: filter.offset ?? filter.skip} |
| 133 | + : {}), |
| 134 | + ...options, |
| 135 | + }); |
| 136 | + return data.map(entity => { |
| 137 | + return entity.toJSON(); |
| 138 | + }); |
| 139 | + } |
| 140 | + |
| 141 | + async findById( |
| 142 | + id: ID, |
| 143 | + filter?: FilterExcludingWhere<T>, |
| 144 | + options?: AnyObject, |
| 145 | + ): Promise<T & Relations> { |
| 146 | + const data = await this.sequelizeModel.findByPk( |
| 147 | + id as unknown as Identifier, |
| 148 | + { |
| 149 | + ...(filter?.fields |
| 150 | + ? {attributes: this.buildSequelizeAttributeFilter(filter.fields)} |
| 151 | + : {}), |
| 152 | + ...(filter?.order |
| 153 | + ? {order: this.buildSequelizeOrder(filter.order)} |
| 154 | + : {}), |
| 155 | + ...(filter?.limit ? {limit: filter.limit} : {}), |
| 156 | + ...(filter?.offset || filter?.skip |
| 157 | + ? {offset: filter.offset ?? filter.skip} |
| 158 | + : {}), |
| 159 | + ...options, |
| 160 | + }, |
| 161 | + ); |
| 162 | + if (!data) { |
| 163 | + throw new EntityNotFoundError(this.entityClass, id); |
| 164 | + } |
| 165 | + // TODO: include relations in object |
| 166 | + return data.toJSON() as T & Relations; |
| 167 | + } |
| 168 | + |
| 169 | + replaceById( |
| 170 | + id: ID, |
| 171 | + data: DataObject<T>, |
| 172 | + options?: AnyObject | undefined, |
| 173 | + ): Promise<void> { |
| 174 | + const idProp = this.modelClass.definition.idName(); |
| 175 | + console.log('idProp', idProp); |
| 176 | + if (idProp in data) { |
| 177 | + delete data[idProp as keyof typeof data]; |
| 178 | + } |
| 179 | + return this.updateById(id, data, options); |
| 180 | + } |
| 181 | + |
| 182 | + async deleteAll( |
| 183 | + where?: Where<T> | undefined, |
| 184 | + options?: AnyObject | undefined, |
| 185 | + ): Promise<Count> { |
| 186 | + const count = await this.sequelizeModel.destroy({ |
| 187 | + where: where ? this.buildSequelizeWhere(where) : {}, |
| 188 | + ...options, |
| 189 | + }); |
| 190 | + return {count}; |
| 191 | + } |
| 192 | + |
| 193 | + async deleteById(id: ID, options?: AnyObject | undefined): Promise<void> { |
| 194 | + if (id === undefined) { |
| 195 | + throw new Error('Invalid Argument: id cannot be undefined'); |
| 196 | + } |
| 197 | + const idProp = this.modelClass.definition.idName(); |
| 198 | + const where = {} as Where<T>; |
| 199 | + (where as AnyObject)[idProp] = id; |
| 200 | + const result = await this.deleteAll(where, options); |
| 201 | + if (result.count === 0) { |
| 202 | + throw new EntityNotFoundError(this.entityClass, id); |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + async count(where?: Where<T>, options?: AnyObject): Promise<Count> { |
| 207 | + const count = await this.sequelizeModel.count({ |
| 208 | + ...(where ? {where: this.buildSequelizeWhere<T>(where)} : {}), |
| 209 | + ...options, |
| 210 | + }); |
| 211 | + |
| 212 | + return {count}; |
| 213 | + } |
| 214 | + |
| 215 | + private getSequelizeOperator(key: keyof typeof operatorTranslations) { |
| 216 | + const sequelizeOperator = operatorTranslations[key]; |
| 217 | + if (!sequelizeOperator) { |
| 218 | + throw Error(`There is no equivalent operator for "${key}" in sequelize.`); |
| 219 | + } |
| 220 | + return sequelizeOperator; |
| 221 | + } |
| 222 | + |
| 223 | + private buildSequelizeAttributeFilter(fields: Fields): FindAttributeOptions { |
| 224 | + if (Array.isArray(fields)) { |
| 225 | + return fields; |
| 226 | + } |
| 227 | + const sequelizeFields: FindAttributeOptions = { |
| 228 | + include: [], |
| 229 | + exclude: [], |
| 230 | + }; |
| 231 | + if (isTruelyObject(fields)) { |
| 232 | + for (const key in fields) { |
| 233 | + if (fields[key] === true) { |
| 234 | + sequelizeFields.include?.push(key); |
| 235 | + } else if (fields[key] === false) { |
| 236 | + sequelizeFields.exclude?.push(key); |
| 237 | + } |
| 238 | + } |
| 239 | + } |
| 240 | + if ( |
| 241 | + Array.isArray(sequelizeFields.include) && |
| 242 | + sequelizeFields.include.length > 0 |
| 243 | + ) { |
| 244 | + delete sequelizeFields.exclude; |
| 245 | + return sequelizeFields.include; |
| 246 | + } |
| 247 | + |
| 248 | + if ( |
| 249 | + Array.isArray(sequelizeFields.exclude) && |
| 250 | + sequelizeFields.exclude.length > 0 |
| 251 | + ) { |
| 252 | + delete sequelizeFields.include; |
| 253 | + } |
| 254 | + return sequelizeFields; |
| 255 | + } |
| 256 | + |
| 257 | + private buildSequelizeOrder(order: string[] | string): Order { |
| 258 | + if (typeof order === 'string') { |
| 259 | + const [columnName, orderType] = order.trim().split(' '); |
| 260 | + return [[columnName, orderType ?? 'ASC']]; |
| 261 | + } |
| 262 | + return order.map(orderStr => { |
| 263 | + const [columnName, orderType] = orderStr.trim().split(' '); |
| 264 | + return [columnName, orderType ?? 'ASC']; |
| 265 | + }); |
| 266 | + } |
| 267 | + |
| 268 | + private buildSequelizeWhere<MT extends T>( |
| 269 | + where: Where<MT>, |
| 270 | + ): WhereOptions<MT> { |
| 271 | + if (!where) { |
| 272 | + return {}; |
| 273 | + } |
| 274 | + |
| 275 | + const sequelizeWhere: WhereOptions = {}; |
| 276 | + for (const columnName in where) { |
| 277 | + console.log('loop for', columnName, 'sequelizeWhere', sequelizeWhere); |
| 278 | + /** |
| 279 | + * Handle model attribute conditions like `{ age: { gt: 18 } }`, `{ email: "a@b.c" }` |
| 280 | + * Transform Operators - eg. `{ gt: 0, lt: 10 }` to `{ [Op.gt]: 0, [Op.lt]: 10 }` |
| 281 | + */ |
| 282 | + const conditionValue = <Object | Array<Object> | number | string | null>( |
| 283 | + where[columnName as keyof typeof where] |
| 284 | + ); |
| 285 | + if (isTruelyObject(conditionValue)) { |
| 286 | + sequelizeWhere[columnName] = {}; |
| 287 | + for (const lb4Operator of Object.keys(<Object>conditionValue)) { |
| 288 | + const sequelizeOperator = this.getSequelizeOperator( |
| 289 | + lb4Operator as keyof typeof operatorTranslations, |
| 290 | + ); |
| 291 | + sequelizeWhere[columnName][sequelizeOperator] = |
| 292 | + conditionValue![lb4Operator as keyof typeof conditionValue]; |
| 293 | + |
| 294 | + console.log( |
| 295 | + 'build ', |
| 296 | + columnName, |
| 297 | + sequelizeWhere[columnName][sequelizeOperator], |
| 298 | + ); |
| 299 | + } |
| 300 | + } else if ( |
| 301 | + ['and', 'or'].includes(columnName) && |
| 302 | + Array.isArray(conditionValue) |
| 303 | + ) { |
| 304 | + /** |
| 305 | + * Eg. {and: [{title: 'My Post'}, {content: 'Hello'}]} |
| 306 | + */ |
| 307 | + const sequelizeOperator = this.getSequelizeOperator( |
| 308 | + columnName as 'and' | 'or', |
| 309 | + ); |
| 310 | + const conditions = conditionValue.map((condition: unknown) => { |
| 311 | + return this.buildSequelizeWhere<MT>(condition as Where<MT>); |
| 312 | + }); |
| 313 | + Object.assign(sequelizeWhere, { |
| 314 | + [sequelizeOperator]: conditions, |
| 315 | + }); |
| 316 | + } else { |
| 317 | + // equals |
| 318 | + console.log('equals column name', columnName); |
| 319 | + sequelizeWhere[columnName] = { |
| 320 | + [Op.eq]: conditionValue, |
| 321 | + }; |
| 322 | + } |
| 323 | + } |
| 324 | + |
| 325 | + console.log('build sequelize where', where, '=>', sequelizeWhere); |
| 326 | + return sequelizeWhere; |
| 327 | + } |
| 328 | + |
| 329 | + private getSequelizeModel() { |
| 330 | + if (!this.dataSource.sequelize) { |
| 331 | + throw Error( |
| 332 | + `The datasource "${this.dataSource.name}" doesn't have sequelize instance bound to it.`, |
| 333 | + ); |
| 334 | + } |
| 335 | + console.log('Modelname', this.entityClass.modelName); |
| 336 | + |
| 337 | + if (this.dataSource.sequelize.models[this.entityClass.modelName]) { |
| 338 | + console.log('target sequelize returned.'); |
| 339 | + return this.dataSource.sequelize.models[this.entityClass.modelName]; |
| 340 | + } |
| 341 | + |
| 342 | + this.dataSource.sequelize.define( |
| 343 | + this.entityClass.modelName, |
| 344 | + this.getSequelizeModelAttributes(this.entityClass.definition.properties), |
| 345 | + { |
| 346 | + createdAt: false, |
| 347 | + updatedAt: false, |
| 348 | + tableName: this.entityClass.modelName.toLowerCase(), |
| 349 | + }, |
| 350 | + ); |
| 351 | + return this.dataSource.sequelize.models[this.entityClass.modelName]; |
| 352 | + } |
| 353 | + |
| 354 | + private getSequelizeModelAttributes(definition: { |
| 355 | + [name: string]: PropertyDefinition; |
| 356 | + }): ModelAttributes<SequelizeModel, Attributes<SequelizeModel>> { |
| 357 | + const sequelizeDefinition: ModelAttributes = {}; |
| 358 | + for (const propName in definition) { |
| 359 | + // Set data type |
| 360 | + let dataType: DataType = DataTypes.STRING; |
| 361 | + if (definition[propName].type === 'Number') { |
| 362 | + dataType = DataTypes.NUMBER; |
| 363 | + } |
| 364 | + const columnOptions: ModelAttributeColumnOptions = { |
| 365 | + type: dataType, |
| 366 | + }; |
| 367 | + |
| 368 | + // set column as `primaryKey` when id is set to true (which is loopback way to define pk) |
| 369 | + if (definition[propName].id === true) { |
| 370 | + console.log(definition[propName]); |
| 371 | + Object.assign(columnOptions, { |
| 372 | + primaryKey: true, |
| 373 | + autoIncrement: true, |
| 374 | + } as typeof columnOptions); |
| 375 | + } |
| 376 | + console.log('columnOptions for', propName, columnOptions); |
| 377 | + sequelizeDefinition[propName] = columnOptions; |
| 378 | + } |
| 379 | + console.log('definition returned', sequelizeDefinition); |
| 380 | + return sequelizeDefinition; |
| 381 | + } |
| 382 | +} |
0 commit comments