has-many.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import type { ForeignKey, HasManySetAssociationsMixin, InferAttributes } from '@sequelize/core';
  2. import { DataTypes, Model, Op } from '@sequelize/core';
  3. import { expect } from 'chai';
  4. import each from 'lodash/each';
  5. import type { SinonStub } from 'sinon';
  6. import sinon from 'sinon';
  7. import { beforeAll2, getTestDialectTeaser, sequelize } from '../../support';
  8. describe(getTestDialectTeaser('hasMany'), () => {
  9. it('throws when invalid model is passed', () => {
  10. const User = sequelize.define('User');
  11. expect(() => {
  12. // @ts-expect-error -- testing that invalid input results in error
  13. User.hasMany();
  14. }).to.throw(
  15. `User.hasMany was called with undefined as the target model, but it is not a subclass of Sequelize's Model class`,
  16. );
  17. });
  18. it('forbids alias inference in self-associations', () => {
  19. const User = sequelize.define('User');
  20. expect(() => {
  21. User.hasMany(User);
  22. }).to.throwWithCause(
  23. 'Both options "as" and "inverse.as" must be defined for hasMany self-associations, and their value must be different',
  24. );
  25. });
  26. it('allows self-associations with explicit alias', () => {
  27. const Category = sequelize.define('Category');
  28. Category.hasMany(Category, { as: 'childCategories', inverse: { as: 'parentCategory' } });
  29. });
  30. it('allows customizing the inverse association name (long form)', () => {
  31. const User = sequelize.define('User');
  32. const Task = sequelize.define('Task');
  33. User.hasMany(Task, { as: 'tasks', inverse: { as: 'user' } });
  34. expect(Task.associations.user).to.be.ok;
  35. expect(User.associations.tasks).to.be.ok;
  36. });
  37. it('allows customizing the inverse association name (shorthand)', () => {
  38. const User = sequelize.define('User');
  39. const Task = sequelize.define('Task');
  40. User.hasMany(Task, { as: 'tasks', inverse: 'user' });
  41. expect(Task.associations.user).to.be.ok;
  42. expect(User.associations.tasks).to.be.ok;
  43. });
  44. it('generates a default association name', () => {
  45. const User = sequelize.define('User', {});
  46. const Task = sequelize.define('Task', {});
  47. User.hasMany(Task);
  48. expect(Object.keys(Task.associations)).to.deep.eq(['user']);
  49. expect(Object.keys(User.associations)).to.deep.eq(['tasks']);
  50. });
  51. describe('optimizations using bulk create, destroy and update', () => {
  52. const vars = beforeAll2(() => {
  53. class User extends Model<InferAttributes<User>> {
  54. declare setTasks: HasManySetAssociationsMixin<Task, number>;
  55. }
  56. class Task extends Model<InferAttributes<Task>> {}
  57. User.init({ username: DataTypes.STRING }, { sequelize });
  58. Task.init({ title: DataTypes.STRING }, { sequelize });
  59. User.hasMany(Task);
  60. const user = User.build({
  61. id: 42,
  62. });
  63. const task1 = Task.build({
  64. id: 15,
  65. });
  66. const task2 = Task.build({
  67. id: 16,
  68. });
  69. return { User, Task, user, task1, task2 };
  70. });
  71. let findAll: SinonStub;
  72. let update: SinonStub;
  73. beforeEach(() => {
  74. const { Task } = vars;
  75. findAll = sinon.stub(Task, 'findAll').resolves([]);
  76. update = sinon.stub(Task, 'update').resolves([0]);
  77. });
  78. afterEach(() => {
  79. findAll.restore();
  80. update.restore();
  81. });
  82. it('uses one update statement for addition', async () => {
  83. const { user, task1, task2 } = vars;
  84. await user.setTasks([task1, task2]);
  85. expect(findAll).to.have.been.calledOnce;
  86. expect(update).to.have.been.calledOnce;
  87. });
  88. it('uses one delete from statement', async () => {
  89. const { user, task1, task2 } = vars;
  90. findAll
  91. .onFirstCall()
  92. .resolves([])
  93. .onSecondCall()
  94. .resolves([
  95. { userId: 42, taskId: 15 },
  96. { userId: 42, taskId: 16 },
  97. ]);
  98. await user.setTasks([task1, task2]);
  99. update.resetHistory();
  100. await user.setTasks([]);
  101. expect(findAll).to.have.been.calledTwice;
  102. expect(update).to.have.been.calledOnce;
  103. });
  104. });
  105. describe('mixin', () => {
  106. const vars = beforeAll2(() => {
  107. const User = sequelize.define('User');
  108. const Task = sequelize.define('Task');
  109. return { User, Task };
  110. });
  111. it('should mixin association methods', () => {
  112. const { User, Task } = vars;
  113. const as = Math.random().toString();
  114. const association = User.hasMany(Task, { as });
  115. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  116. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  117. // @ts-ignore -- This only became invalid starting with TS 5.4
  118. expect(User.prototype[association.accessors.get]).to.be.a('function');
  119. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  120. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  121. // @ts-ignore -- This only became invalid starting with TS 5.4
  122. expect(User.prototype[association.accessors.set]).to.be.a('function');
  123. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  124. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  125. // @ts-ignore -- This only became invalid starting with TS 5.4
  126. expect(User.prototype[association.accessors.addMultiple]).to.be.a('function');
  127. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  128. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  129. // @ts-ignore -- This only became invalid starting with TS 5.4
  130. expect(User.prototype[association.accessors.add]).to.be.a('function');
  131. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  132. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  133. // @ts-ignore -- This only became invalid starting with TS 5.4
  134. expect(User.prototype[association.accessors.remove]).to.be.a('function');
  135. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  136. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  137. // @ts-ignore -- This only became invalid starting with TS 5.4
  138. expect(User.prototype[association.accessors.removeMultiple]).to.be.a('function');
  139. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  140. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  141. // @ts-ignore -- This only became invalid starting with TS 5.4
  142. expect(User.prototype[association.accessors.hasSingle]).to.be.a('function');
  143. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  144. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  145. // @ts-ignore -- This only became invalid starting with TS 5.4
  146. expect(User.prototype[association.accessors.hasAll]).to.be.a('function');
  147. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  148. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  149. // @ts-ignore -- This only became invalid starting with TS 5.4
  150. expect(User.prototype[association.accessors.count]).to.be.a('function');
  151. });
  152. it('should not override custom methods', () => {
  153. const { User, Task } = vars;
  154. const methods = {
  155. getTasks: 'get',
  156. countTasks: 'count',
  157. hasTask: 'has',
  158. hasTasks: 'has',
  159. setTasks: 'set',
  160. addTask: 'add',
  161. addTasks: 'add',
  162. removeTask: 'remove',
  163. removeTasks: 'remove',
  164. createTask: 'create',
  165. };
  166. function originalMethod() {}
  167. each(methods, (alias, method) => {
  168. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  169. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  170. // @ts-ignore -- This only became invalid starting with TS 5.4
  171. User.prototype[method] = originalMethod;
  172. });
  173. User.hasMany(Task, { as: 'task' });
  174. const user = User.build();
  175. each(methods, (alias, method) => {
  176. // @ts-expect-error -- dynamic type, not worth typing
  177. expect(user[method]).to.eq(originalMethod);
  178. });
  179. });
  180. it('should not override attributes', () => {
  181. const { Task } = vars;
  182. class Project extends Model<InferAttributes<Project>> {
  183. declare hasTasks: boolean | null;
  184. }
  185. Project.init(
  186. {
  187. hasTasks: DataTypes.BOOLEAN,
  188. },
  189. { sequelize },
  190. );
  191. Project.hasMany(Task);
  192. const project = Project.build();
  193. expect(project.hasTasks).not.to.be.a('function');
  194. });
  195. });
  196. describe('get', () => {
  197. function getModels() {
  198. class User extends Model<InferAttributes<User>> {}
  199. class Task extends Model<InferAttributes<Task>> {
  200. declare user_id: ForeignKey<string | null>;
  201. }
  202. User.init(
  203. {
  204. id: {
  205. type: DataTypes.STRING,
  206. primaryKey: true,
  207. },
  208. },
  209. { sequelize },
  210. );
  211. Task.init({}, { sequelize });
  212. return { Task, User };
  213. }
  214. const idA = Math.random().toString();
  215. const idB = Math.random().toString();
  216. const idC = Math.random().toString();
  217. const foreignKey = 'user_id';
  218. it('should fetch associations for a single instance', async () => {
  219. const { Task, User } = getModels();
  220. const findAll = sinon.stub(Task, 'findAll').resolves([Task.build({}), Task.build({})]);
  221. const UserTasks = User.hasMany(Task, { foreignKey });
  222. const actual = UserTasks.get(User.build({ id: idA }));
  223. const where = {
  224. [foreignKey]: idA,
  225. };
  226. expect(findAll).to.have.been.calledOnce;
  227. expect(findAll.firstCall.args[0]?.where).to.deep.equal(where);
  228. try {
  229. const results = await actual;
  230. expect(results).to.be.an('array');
  231. expect(results.length).to.equal(2);
  232. } finally {
  233. findAll.restore();
  234. }
  235. });
  236. it('should fetch associations for multiple source instances', async () => {
  237. const { Task, User } = getModels();
  238. const UserTasks = User.hasMany(Task, { foreignKey });
  239. const findAll = sinon.stub(Task, 'findAll').returns(
  240. Promise.resolve([
  241. Task.build({
  242. user_id: idA,
  243. }),
  244. Task.build({
  245. user_id: idA,
  246. }),
  247. Task.build({
  248. user_id: idA,
  249. }),
  250. Task.build({
  251. user_id: idB,
  252. }),
  253. ]),
  254. );
  255. const actual = UserTasks.get([
  256. User.build({ id: idA }),
  257. User.build({ id: idB }),
  258. User.build({ id: idC }),
  259. ]);
  260. expect(findAll).to.have.been.calledOnce;
  261. expect(findAll.firstCall.args[0]?.where).to.have.property(foreignKey);
  262. // @ts-expect-error -- not worth typing for this test
  263. expect(findAll.firstCall.args[0]?.where[foreignKey]).to.have.property(Op.in);
  264. // @ts-expect-error -- not worth typing for this test
  265. expect(findAll.firstCall.args[0]?.where[foreignKey][Op.in]).to.deep.equal([idA, idB, idC]);
  266. try {
  267. const result = await actual;
  268. expect(result).to.be.instanceOf(Map);
  269. expect([...result.keys()]).to.deep.equal([idA, idB, idC]);
  270. expect(result.get(idA)?.length).to.equal(3);
  271. expect(result.get(idB)?.length).to.equal(1);
  272. expect(result.get(idC)?.length).to.equal(0);
  273. } finally {
  274. findAll.restore();
  275. }
  276. });
  277. });
  278. describe('association hooks', () => {
  279. function getModels() {
  280. class Project extends Model<InferAttributes<Project>> {
  281. declare title: string | null;
  282. }
  283. class Task extends Model<InferAttributes<Task>> {
  284. declare user_id: ForeignKey<string | null>;
  285. declare title: string | null;
  286. }
  287. Project.init({ title: DataTypes.STRING }, { sequelize });
  288. Task.init({ title: DataTypes.STRING }, { sequelize });
  289. return { Task, Project };
  290. }
  291. describe('beforeHasManyAssociate', () => {
  292. it('should trigger', () => {
  293. const { Task, Project } = getModels();
  294. const beforeAssociate = sinon.spy();
  295. Project.beforeAssociate(beforeAssociate);
  296. Project.hasMany(Task, { hooks: true });
  297. const beforeAssociateArgs = beforeAssociate.getCall(0).args;
  298. expect(beforeAssociate).to.have.been.called;
  299. expect(beforeAssociateArgs.length).to.equal(2);
  300. const firstArg = beforeAssociateArgs[0];
  301. expect(Object.keys(firstArg).join(',')).to.equal('source,target,type,sequelize');
  302. expect(firstArg.source).to.equal(Project);
  303. expect(firstArg.target).to.equal(Task);
  304. expect(firstArg.type.name).to.equal('HasMany');
  305. expect(firstArg.sequelize.constructor.name).to.equal('Sequelize');
  306. });
  307. it('should not trigger association hooks', () => {
  308. const { Task, Project } = getModels();
  309. const beforeAssociate = sinon.spy();
  310. Project.beforeAssociate(beforeAssociate);
  311. Project.hasMany(Task, { hooks: false });
  312. expect(beforeAssociate).to.not.have.been.called;
  313. });
  314. });
  315. describe('afterHasManyAssociate', () => {
  316. it('should trigger', () => {
  317. const { Task, Project } = getModels();
  318. const afterAssociate = sinon.spy();
  319. Project.afterAssociate(afterAssociate);
  320. Project.hasMany(Task, { hooks: true });
  321. const afterAssociateArgs = afterAssociate.getCall(0).args;
  322. expect(afterAssociate).to.have.been.called;
  323. const firstArg = afterAssociateArgs[0];
  324. expect(Object.keys(firstArg).join(',')).to.equal(
  325. 'source,target,type,association,sequelize',
  326. );
  327. expect(firstArg.source).to.equal(Project);
  328. expect(firstArg.target).to.equal(Task);
  329. expect(firstArg.type.name).to.equal('HasMany');
  330. expect(firstArg.association.constructor.name).to.equal('HasMany');
  331. expect(firstArg.sequelize.constructor.name).to.equal('Sequelize');
  332. });
  333. it('should not trigger association hooks', () => {
  334. const { Task, Project } = getModels();
  335. const afterAssociate = sinon.spy();
  336. Project.afterAssociate(afterAssociate);
  337. Project.hasMany(Task, { hooks: false });
  338. expect(afterAssociate).to.not.have.been.called;
  339. });
  340. });
  341. });
  342. });