register-custom-entity-fields.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. /* eslint-disable @typescript-eslint/ban-types */
  2. import { CustomFieldType } from '@vendure/common/lib/shared-types';
  3. import { assertNever } from '@vendure/common/lib/shared-utils';
  4. import {
  5. Column,
  6. ColumnOptions,
  7. ColumnType,
  8. DataSourceOptions,
  9. getMetadataArgsStorage,
  10. Index,
  11. JoinColumn,
  12. JoinTable,
  13. ManyToMany,
  14. ManyToOne,
  15. } from 'typeorm';
  16. import { EmbeddedMetadataArgs } from 'typeorm/metadata-args/EmbeddedMetadataArgs';
  17. import { DateUtils } from 'typeorm/util/DateUtils';
  18. import { CustomFieldConfig, CustomFields } from '../config/custom-field/custom-field-types';
  19. import { Logger } from '../config/logger/vendure-logger';
  20. import { VendureConfig } from '../config/vendure-config';
  21. /**
  22. * The maximum length of the "length" argument of a MySQL varchar column.
  23. */
  24. const MAX_STRING_LENGTH = 65535;
  25. /**
  26. * Dynamically add columns to the custom field entity based on the CustomFields config.
  27. */
  28. function registerCustomFieldsForEntity(
  29. config: VendureConfig,
  30. entityName: keyof CustomFields,
  31. // eslint-disable-next-line @typescript-eslint/prefer-function-type
  32. ctor: { new (): any },
  33. translation = false,
  34. ) {
  35. const customFields = config.customFields && config.customFields[entityName];
  36. const dbEngine = config.dbConnectionOptions.type;
  37. if (customFields) {
  38. for (const customField of customFields) {
  39. const { name, list, defaultValue, nullable } = customField;
  40. const instance = new ctor();
  41. const registerColumn = () => {
  42. if (customField.type === 'relation') {
  43. if (customField.list) {
  44. ManyToMany(type => customField.entity, customField.inverseSide, {
  45. eager: customField.eager,
  46. })(instance, name);
  47. JoinTable()(instance, name);
  48. } else {
  49. ManyToOne(type => customField.entity, customField.inverseSide, {
  50. eager: customField.eager,
  51. })(instance, name);
  52. JoinColumn()(instance, name);
  53. }
  54. } else {
  55. const options: ColumnOptions = {
  56. type: getColumnType(dbEngine, customField.type, list ?? false),
  57. default: getDefault(customField, dbEngine),
  58. name,
  59. nullable: nullable === false ? false : true,
  60. unique: customField.unique ?? false,
  61. };
  62. if ((customField.type === 'string' || customField.type === 'localeString') && !list) {
  63. const length = customField.length || 255;
  64. if (MAX_STRING_LENGTH < length) {
  65. throw new Error(
  66. `ERROR: The "length" property of the custom field "${customField.name}" is ` +
  67. `greater than the maximum allowed value of ${MAX_STRING_LENGTH}`,
  68. );
  69. }
  70. options.length = length;
  71. }
  72. if (
  73. customField.type === 'float' &&
  74. typeof customField.defaultValue === 'number' &&
  75. (dbEngine === 'mariadb' || dbEngine === 'mysql')
  76. ) {
  77. // In the MySQL driver, a default float value will get rounded to the nearest integer.
  78. // unless you specify the precision.
  79. const defaultValueDecimalPlaces = customField.defaultValue.toString().split('.')[1];
  80. if (defaultValueDecimalPlaces) {
  81. options.scale = defaultValueDecimalPlaces.length;
  82. }
  83. }
  84. if (
  85. customField.type === 'datetime' &&
  86. options.precision == null &&
  87. // Setting precision on an sqlite datetime will cause
  88. // spurious migration commands. See https://github.com/typeorm/typeorm/issues/2333
  89. dbEngine !== 'sqljs' &&
  90. dbEngine !== 'sqlite' &&
  91. !list
  92. ) {
  93. options.precision = 6;
  94. }
  95. Column(options)(instance, name);
  96. if ((dbEngine === 'mysql' || dbEngine === 'mariadb') && customField.unique === true) {
  97. // The MySQL driver seems to work differently and will only apply a unique
  98. // constraint if an index is defined on the column. For postgres/sqlite it is
  99. // sufficient to add the `unique: true` property to the column options.
  100. Index({ unique: true })(instance, name);
  101. }
  102. }
  103. };
  104. if (translation) {
  105. if (customField.type === 'localeString' || customField.type === 'localeText') {
  106. registerColumn();
  107. }
  108. } else {
  109. if (customField.type !== 'localeString' && customField.type !== 'localeText') {
  110. registerColumn();
  111. }
  112. }
  113. const relationFieldsCount = customFields.filter(f => f.type === 'relation').length;
  114. const nonLocaleStringFieldsCount = customFields.filter(
  115. f => f.type !== 'localeString' && f.type !== 'localeText' && f.type !== 'relation',
  116. ).length;
  117. if (0 < relationFieldsCount && nonLocaleStringFieldsCount === 0) {
  118. // if (customFields.filter(f => f.type === 'relation').length === customFields.length) {
  119. // If there are _only_ relational customFields defined for an Entity, then TypeORM
  120. // errors when attempting to load that entity ("Cannot set property <fieldName> of undefined").
  121. // Therefore as a work-around we will add a "fake" column to the customFields embedded type
  122. // to prevent this error from occurring.
  123. Column({
  124. type: 'boolean',
  125. nullable: true,
  126. comment:
  127. 'A work-around needed when only relational custom fields are defined on an entity',
  128. })(instance, '__fix_relational_custom_fields__');
  129. }
  130. }
  131. }
  132. }
  133. function formatDefaultDatetime(dbEngine: DataSourceOptions['type'], datetime: any): Date | string {
  134. if (!datetime) {
  135. return datetime;
  136. }
  137. switch (dbEngine) {
  138. case 'sqlite':
  139. case 'sqljs':
  140. return DateUtils.mixedDateToUtcDatetimeString(datetime);
  141. case 'mysql':
  142. case 'postgres':
  143. default:
  144. return DateUtils.mixedDateToUtcDatetimeString(datetime);
  145. // return DateUtils.mixedDateToDate(datetime, true, true);
  146. }
  147. }
  148. function getColumnType(
  149. dbEngine: DataSourceOptions['type'],
  150. type: Exclude<CustomFieldType, 'relation'>,
  151. isList: boolean,
  152. ): ColumnType {
  153. if (isList && type !== 'struct') {
  154. return 'simple-json';
  155. }
  156. switch (type) {
  157. case 'string':
  158. case 'localeString':
  159. return 'varchar';
  160. case 'text':
  161. case 'localeText':
  162. switch (dbEngine) {
  163. case 'mysql':
  164. case 'mariadb':
  165. return 'longtext';
  166. default:
  167. return 'text';
  168. }
  169. case 'boolean':
  170. switch (dbEngine) {
  171. case 'mysql':
  172. return 'tinyint';
  173. case 'postgres':
  174. return 'bool';
  175. case 'sqlite':
  176. case 'sqljs':
  177. default:
  178. return 'boolean';
  179. }
  180. case 'int':
  181. return 'int';
  182. case 'float':
  183. return 'double precision';
  184. case 'datetime':
  185. switch (dbEngine) {
  186. case 'postgres':
  187. return 'timestamp';
  188. case 'mysql':
  189. case 'sqlite':
  190. case 'sqljs':
  191. default:
  192. return 'datetime';
  193. }
  194. case 'struct':
  195. switch (dbEngine) {
  196. case 'postgres':
  197. return 'jsonb';
  198. case 'mysql':
  199. case 'mariadb':
  200. return 'json';
  201. case 'sqlite':
  202. case 'sqljs':
  203. default:
  204. return 'simple-json';
  205. }
  206. default:
  207. assertNever(type);
  208. }
  209. return 'varchar';
  210. }
  211. function getDefault(customField: CustomFieldConfig, dbEngine: DataSourceOptions['type']) {
  212. const { name, type, list, defaultValue, nullable } = customField;
  213. if (list && defaultValue) {
  214. if (dbEngine === 'mysql') {
  215. // MySQL does not support defaults on TEXT fields, which is what "simple-json" uses
  216. // internally. See https://stackoverflow.com/q/3466872/772859
  217. Logger.warn(
  218. `MySQL does not support default values on list fields (${name}). No default will be set.`,
  219. );
  220. return undefined;
  221. }
  222. return JSON.stringify(defaultValue);
  223. }
  224. return type === 'datetime' ? formatDefaultDatetime(dbEngine, defaultValue) : defaultValue;
  225. }
  226. function assertLocaleFieldsNotSpecified(config: VendureConfig, entityName: keyof CustomFields) {
  227. const customFields = config.customFields && config.customFields[entityName];
  228. if (customFields) {
  229. for (const customField of customFields) {
  230. if (customField.type === 'localeString' || customField.type === 'localeText') {
  231. Logger.error(
  232. `Custom field "${customField.name}" on entity "${entityName}" cannot be of type "localeString" or "localeText". ` +
  233. `This entity does not support localization.`,
  234. );
  235. }
  236. }
  237. }
  238. }
  239. /**
  240. * Dynamically registers any custom fields with TypeORM. This function should be run at the bootstrap
  241. * stage of the app lifecycle, before the AppModule is initialized.
  242. */
  243. export function registerCustomEntityFields(config: VendureConfig) {
  244. // In order to determine the classes used for the custom field embedded types, we need
  245. // to introspect the metadata args storage.
  246. const metadataArgsStorage = getMetadataArgsStorage();
  247. for (const [entityName, customFieldsConfig] of Object.entries(config.customFields ?? {})) {
  248. if (customFieldsConfig && customFieldsConfig.length) {
  249. const customFieldsMetadata = getCustomFieldsMetadata(entityName);
  250. const customFieldsClass = customFieldsMetadata.type();
  251. if (customFieldsClass && typeof customFieldsClass !== 'string') {
  252. registerCustomFieldsForEntity(config, entityName, customFieldsClass as any);
  253. }
  254. const translationsMetadata = metadataArgsStorage
  255. .filterRelations(customFieldsMetadata.target)
  256. .find(m => m.propertyName === 'translations');
  257. if (translationsMetadata) {
  258. // This entity is translatable, which means that we should
  259. // also register any localized custom fields on the related
  260. // EntityTranslation entity.
  261. const translationType: Function = (translationsMetadata.type as Function)();
  262. const customFieldsTranslationsMetadata = getCustomFieldsMetadata(translationType);
  263. const customFieldsTranslationClass = customFieldsTranslationsMetadata.type();
  264. if (customFieldsTranslationClass && typeof customFieldsTranslationClass !== 'string') {
  265. registerCustomFieldsForEntity(
  266. config,
  267. entityName,
  268. customFieldsTranslationClass as any,
  269. true,
  270. );
  271. }
  272. } else {
  273. assertLocaleFieldsNotSpecified(config, entityName);
  274. }
  275. }
  276. }
  277. function getCustomFieldsMetadata(entity: Function | string): EmbeddedMetadataArgs {
  278. const entityName = typeof entity === 'string' ? entity : entity.name;
  279. const metadataArgs = metadataArgsStorage.embeddeds.find(item => {
  280. if (item.propertyName === 'customFields') {
  281. const targetName = typeof item.target === 'string' ? item.target : item.target.name;
  282. return targetName === entityName;
  283. }
  284. });
  285. if (!metadataArgs) {
  286. throw new Error(`Could not find embedded CustomFields property on entity "${entityName}"`);
  287. }
  288. return metadataArgs;
  289. }
  290. }