has-one.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import type { ModelStatic } from '@sequelize/core';
  2. import { DataTypes } from '@sequelize/core';
  3. import { expect } from 'chai';
  4. import each from 'lodash/each';
  5. import assert from 'node:assert';
  6. import sinon from 'sinon';
  7. import { getTestDialectTeaser, sequelize } from '../../support';
  8. describe(getTestDialectTeaser('hasOne'), () => {
  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.hasOne();
  14. }).to.throw(
  15. `User.hasOne was called with undefined as the target model, but it is not a subclass of Sequelize's Model class`,
  16. );
  17. });
  18. it('warn on invalid options', () => {
  19. const User = sequelize.define('User', {});
  20. const Task = sequelize.define('Task', {});
  21. expect(() => {
  22. User.hasOne(Task, { sourceKey: 'wowow' });
  23. }).to.throwWithCause(
  24. 'Unknown attribute "wowow" passed as sourceKey, define this attribute on model "User" first',
  25. );
  26. });
  27. it('forbids alias inference in self-associations', () => {
  28. const User = sequelize.define('User');
  29. expect(() => {
  30. User.hasOne(User);
  31. }).to.throwWithCause(
  32. 'Both options "as" and "inverse.as" must be defined for hasOne self-associations, and their value must be different',
  33. );
  34. });
  35. it('allows self-associations with explicit alias', () => {
  36. const User = sequelize.define('User');
  37. // this would make more sense as a belongsTo(User, { as: 'mother', inverse: { type: 'many', as: 'children' } })
  38. User.hasOne(User, { as: 'mother', inverse: { as: 'child' } });
  39. });
  40. it('allows customizing the inverse association name (long form)', () => {
  41. const User = sequelize.define('User');
  42. const Task = sequelize.define('Task');
  43. User.hasMany(Task, { as: 'task', inverse: { as: 'user' } });
  44. expect(Task.associations.user).to.be.ok;
  45. expect(User.associations.task).to.be.ok;
  46. });
  47. it('allows customizing the inverse association name (shorthand)', () => {
  48. const User = sequelize.define('User');
  49. const Task = sequelize.define('Task');
  50. User.hasMany(Task, { as: 'task', inverse: 'user' });
  51. expect(Task.associations.user).to.be.ok;
  52. expect(User.associations.task).to.be.ok;
  53. });
  54. it('generates a default association name', () => {
  55. const User = sequelize.define('User', {});
  56. const Task = sequelize.define('Task', {});
  57. User.hasOne(Task);
  58. expect(Object.keys(Task.associations)).to.deep.eq(['user']);
  59. expect(Object.keys(User.associations)).to.deep.eq(['task']);
  60. });
  61. it('does not use `as` option to generate foreign key name', () => {
  62. // See HasOne.inferForeignKey for explanations as to why "as" is not used when inferring the foreign key.
  63. const User = sequelize.define('User', { username: DataTypes.STRING });
  64. const Task = sequelize.define('Task', { title: DataTypes.STRING });
  65. const association1 = User.hasOne(Task);
  66. expect(association1.foreignKey).to.equal('userId');
  67. expect(Task.getAttributes().userId).not.to.be.empty;
  68. const association2 = User.hasOne(Task, { as: 'Shabda' });
  69. expect(association2.foreignKey).to.equal('userId');
  70. expect(Task.getAttributes().userId).not.to.be.empty;
  71. });
  72. it('should not override custom methods with association mixin', () => {
  73. const methods = {
  74. getTask: 'get',
  75. setTask: 'set',
  76. createTask: 'create',
  77. };
  78. const User = sequelize.define('User');
  79. const Task = sequelize.define('Task');
  80. function originalFunction() {}
  81. each(methods, (alias, method) => {
  82. // TODO: remove this eslint-disable once we drop support for TypeScript <= 5.3
  83. // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
  84. // @ts-ignore -- This only became invalid starting with TS 5.4
  85. User.prototype[method] = originalFunction;
  86. });
  87. User.hasOne(Task, { as: 'task' });
  88. const user = User.build();
  89. each(methods, (alias, method) => {
  90. // @ts-expect-error -- dynamic type, not worth typing
  91. expect(user[method]).to.eq(originalFunction);
  92. });
  93. });
  94. describe('allows the user to provide an attribute definition object as foreignKey', () => {
  95. it(`works with a column that hasn't been defined before`, () => {
  96. const User = sequelize.define('user', {});
  97. const Profile = sequelize.define('project', {});
  98. User.hasOne(Profile, {
  99. foreignKey: {
  100. allowNull: false,
  101. name: 'uid',
  102. },
  103. });
  104. expect(Profile.getAttributes().uid).to.be.ok;
  105. const model = Profile.getAttributes().uid.references?.table;
  106. assert(typeof model === 'object');
  107. expect(model).to.deep.equal(User.table);
  108. expect(Profile.getAttributes().uid.references?.key).to.equal('id');
  109. expect(Profile.getAttributes().uid.allowNull).to.be.false;
  110. });
  111. it('works when taking a column directly from the object', () => {
  112. const User = sequelize.define('user', {
  113. uid: {
  114. type: DataTypes.INTEGER,
  115. primaryKey: true,
  116. },
  117. });
  118. const Profile = sequelize.define('project', {
  119. user_id: {
  120. type: DataTypes.INTEGER,
  121. allowNull: false,
  122. },
  123. });
  124. User.hasOne(Profile, { foreignKey: Profile.getAttributes().user_id });
  125. expect(Profile.getAttributes().user_id).to.be.ok;
  126. const targetTable = Profile.getAttributes().user_id.references?.table;
  127. assert(typeof targetTable === 'object');
  128. expect(targetTable).to.deep.equal(User.table);
  129. expect(Profile.getAttributes().user_id.references?.key).to.equal('uid');
  130. expect(Profile.getAttributes().user_id.allowNull).to.be.false;
  131. });
  132. it('works when merging with an existing definition', () => {
  133. const User = sequelize.define('user', {
  134. uid: {
  135. type: DataTypes.INTEGER,
  136. primaryKey: true,
  137. },
  138. });
  139. const Project = sequelize.define('project', {
  140. userUid: {
  141. type: DataTypes.INTEGER,
  142. defaultValue: 42,
  143. },
  144. });
  145. User.hasOne(Project, { foreignKey: { allowNull: false } });
  146. expect(Project.getAttributes().userUid).to.be.ok;
  147. expect(Project.getAttributes().userUid.allowNull).to.be.false;
  148. const targetTable = Project.getAttributes().userUid.references?.table;
  149. assert(typeof targetTable === 'object');
  150. expect(targetTable).to.deep.equal(User.table);
  151. expect(Project.getAttributes().userUid.references?.key).to.equal('uid');
  152. expect(Project.getAttributes().userUid.defaultValue).to.equal(42);
  153. });
  154. });
  155. it('sets the foreign key default onDelete to CASCADE if allowNull: false', async () => {
  156. const Task = sequelize.define('Task', { title: DataTypes.STRING });
  157. const User = sequelize.define('User', { username: DataTypes.STRING });
  158. User.hasOne(Task, { foreignKey: { allowNull: false } });
  159. expect(Task.getAttributes().userId.onDelete).to.eq('CASCADE');
  160. });
  161. it('should throw an error if an association clashes with the name of an already define attribute', () => {
  162. const User = sequelize.define('user', {
  163. attribute: DataTypes.STRING,
  164. });
  165. const Attribute = sequelize.define('attribute', {});
  166. expect(User.hasOne.bind(User, Attribute)).to.throw(
  167. "Naming collision between attribute 'attribute' and association 'attribute' on model user. To remedy this, change the \"as\" options in your association definition",
  168. );
  169. });
  170. describe('Model.associations', () => {
  171. it('should store all associations when associating to the same table multiple times', () => {
  172. const User = sequelize.define('User', {});
  173. const Group = sequelize.define('Group', {});
  174. Group.hasOne(User);
  175. Group.hasOne(User, {
  176. foreignKey: 'primaryGroupId',
  177. as: 'primaryUsers',
  178. inverse: { as: 'primaryGroup' },
  179. });
  180. Group.hasOne(User, {
  181. foreignKey: 'secondaryGroupId',
  182. as: 'secondaryUsers',
  183. inverse: { as: 'secondaryGroup' },
  184. });
  185. expect(Object.keys(Group.associations)).to.deep.equal([
  186. 'user',
  187. 'primaryUsers',
  188. 'secondaryUsers',
  189. ]);
  190. });
  191. });
  192. describe('association hooks', () => {
  193. let Projects: ModelStatic<any>;
  194. let Tasks: ModelStatic<any>;
  195. beforeEach(() => {
  196. Projects = sequelize.define('Project', { title: DataTypes.STRING });
  197. Tasks = sequelize.define('Task', { title: DataTypes.STRING });
  198. });
  199. describe('beforeHasOneAssociate', () => {
  200. it('should trigger', () => {
  201. const beforeAssociate = sinon.spy();
  202. Projects.beforeAssociate(beforeAssociate);
  203. Projects.hasOne(Tasks, { hooks: true });
  204. const beforeAssociateArgs = beforeAssociate.getCall(0).args;
  205. expect(beforeAssociate).to.have.been.called;
  206. expect(beforeAssociateArgs.length).to.equal(2);
  207. const firstArg = beforeAssociateArgs[0];
  208. expect(Object.keys(firstArg).join(',')).to.equal('source,target,type,sequelize');
  209. expect(firstArg.source).to.equal(Projects);
  210. expect(firstArg.target).to.equal(Tasks);
  211. expect(firstArg.type.name).to.equal('HasOne');
  212. expect(firstArg.sequelize.constructor.name).to.equal('Sequelize');
  213. });
  214. it('should not trigger association hooks', () => {
  215. const beforeAssociate = sinon.spy();
  216. Projects.beforeAssociate(beforeAssociate);
  217. Projects.hasOne(Tasks, { hooks: false });
  218. expect(beforeAssociate).to.not.have.been.called;
  219. });
  220. });
  221. describe('afterHasOneAssociate', () => {
  222. it('should trigger', () => {
  223. const afterAssociate = sinon.spy();
  224. Projects.afterAssociate(afterAssociate);
  225. Projects.hasOne(Tasks, { hooks: true });
  226. const afterAssociateArgs = afterAssociate.getCall(0).args;
  227. expect(afterAssociate).to.have.been.called;
  228. expect(afterAssociateArgs.length).to.equal(2);
  229. const firstArg = afterAssociateArgs[0];
  230. expect(Object.keys(firstArg).join(',')).to.equal(
  231. 'source,target,type,association,sequelize',
  232. );
  233. expect(firstArg.source).to.equal(Projects);
  234. expect(firstArg.target).to.equal(Tasks);
  235. expect(firstArg.type.name).to.equal('HasOne');
  236. expect(firstArg.association.constructor.name).to.equal('HasOne');
  237. expect(firstArg.sequelize.constructor.name).to.equal('Sequelize');
  238. });
  239. it('should not trigger association hooks', () => {
  240. const afterAssociate = sinon.spy();
  241. Projects.afterAssociate(afterAssociate);
  242. Projects.hasOne(Tasks, { hooks: false });
  243. expect(afterAssociate).to.not.have.been.called;
  244. });
  245. });
  246. });
  247. });