@autofleet/sadot 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
- import type { ModelFetcher, Models } from '../types';
1
+ import type { CustomFieldOptions, ModelFetcher, Models } from '../types';
2
2
  export declare const addHooks: (models: Models[], getModel: ModelFetcher, sadotOptions?: {
3
3
  useCustomFieldsEntries: boolean;
4
4
  }) => void;
5
5
  export declare const removeHooks: (models: Models[], getModel: ModelFetcher) => void;
6
- export declare const addScopes: (models: Models[], getModel: ModelFetcher) => void;
6
+ export declare const addScopes: (models: Models[], getModel: ModelFetcher, options?: Pick<CustomFieldOptions, 'useCustomFieldsEntries'>) => void;
7
7
  export declare const applyCustomAssociation: (models: Models[]) => void;
@@ -35,9 +35,9 @@ const addHooks = (models, getModel, sadotOptions = { useCustomFieldsEntries: fal
35
35
  model.addHook('beforeBulkUpdate', 'sadot-beforeBulkUpdate', hooks_1.beforeBulkUpdate);
36
36
  model.addHook('beforeCreate', 'sadot-beforeCreate', (0, hooks_1.beforeCreate)(scopeAttributes, modelOptions, sadotOptions));
37
37
  model.addHook('beforeUpdate', 'sadot-beforeUpdate', (0, hooks_1.beforeUpdate)(scopeAttributes, modelOptions, sadotOptions));
38
- model.addHook('afterFind', 'sadot-afterFind', (0, hooks_1.enrichResults)(name, scopeAttributes, 'afterFind', modelOptions));
39
- model.addHook('afterUpdate', 'sadot-afterUpdate', (0, hooks_1.enrichResults)(name, scopeAttributes, null, modelOptions));
40
- model.addHook('afterCreate', 'sadot-afterCreate', (0, hooks_1.enrichResults)(name, scopeAttributes, null, modelOptions));
38
+ model.addHook('afterFind', 'sadot-afterFind', (0, hooks_1.enrichResults)(name, scopeAttributes, 'afterFind', modelOptions, sadotOptions));
39
+ model.addHook('afterUpdate', 'sadot-afterUpdate', (0, hooks_1.enrichResults)(name, scopeAttributes, null, modelOptions, sadotOptions));
40
+ model.addHook('afterCreate', 'sadot-afterCreate', (0, hooks_1.enrichResults)(name, scopeAttributes, null, modelOptions, sadotOptions));
41
41
  }
42
42
  catch (e) {
43
43
  logger_1.default.error(`Could not add custom fields hook to model ${name}. `, e);
@@ -78,7 +78,7 @@ const addAssociations = (model, modelName) => {
78
78
  // TBC: maybe can be removed
79
79
  models_1.CustomFieldValue.belongsTo(model, { foreignKey: 'modelId', as: modelName });
80
80
  };
81
- const addScopes = (models, getModel) => {
81
+ const addScopes = (models, getModel, options = { useCustomFieldsEntries: false }) => {
82
82
  models.forEach(async ({ name, scopeAttributes }) => {
83
83
  try {
84
84
  const model = getModel(name);
@@ -92,8 +92,8 @@ const addScopes = (models, getModel) => {
92
92
  // Necessary associations for the filtering scope
93
93
  addAssociations(model, name);
94
94
  // Add filter scope
95
- model.addScope(CUSTOM_FIELDS_FILTER_SCOPE, (0, scopes_1.customFieldsFilterScope)(name));
96
- model.addScope(custom_fields_1.CUSTOM_FIELDS_SORT_SCOPE, (0, filter_1.customFieldsSortScope)(name));
95
+ model.addScope(CUSTOM_FIELDS_FILTER_SCOPE, (0, scopes_1.customFieldsFilterScope)(name, options));
96
+ model.addScope(custom_fields_1.CUSTOM_FIELDS_SORT_SCOPE, (0, filter_1.customFieldsSortScope)(name, options));
97
97
  }
98
98
  catch (e) {
99
99
  logger_1.default.error(`Could not add custom fields scopes to model ${name}. `, e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autofleet/sadot",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -45,7 +45,7 @@ export const beforeCreate = (
45
45
  if (fieldsWithDefaultValue.length) {
46
46
  // eslint-disable-next-line no-param-reassign
47
47
  instance.customFields ||= {};
48
- fieldsWithDefaultValue.filter((def) => !(def.name in instance.customFields)).forEach(({ name, defaultValue }) => {
48
+ fieldsWithDefaultValue.filter((def) => (instance.customFields?.[def.name] === undefined)).forEach(({ name, defaultValue }) => {
49
49
  // eslint-disable-next-line no-param-reassign
50
50
  instance.customFields[name] = defaultValue;
51
51
  });
@@ -1,14 +1,86 @@
1
1
  /* eslint-disable no-param-reassign */
2
+ import type { Transaction } from 'sequelize';
2
3
  import * as ValueRepo from '../repository/value';
3
4
  import * as DefinitionRepo from '../repository/definition';
5
+ import * as EntriesRepo from '../repository/entries';
4
6
  import type CustomFieldValue from '../models/CustomFieldValue';
5
7
  import type CustomFieldDefinition from '../models/CustomFieldDefinition';
6
8
  import type { SerializedCustomFields } from '../types/definition';
7
- import type { ModelOptions } from '../types';
9
+ import type { CustomFieldOptions, ModelOptions } from '../types';
8
10
  import applyScopeToInstance from '../utils/scopeAttributes';
9
11
 
10
12
  type SupportedHookTypes = 'afterFind' | 'afterCreate' | 'afterUpdate';
11
13
 
14
+ type CustomFieldEntries = Record<string, any>;
15
+
16
+ interface GetValuesGroupByInstanceResponse {
17
+ [modelId: string]: CustomFieldValue[];
18
+ }
19
+
20
+ interface GetCustomFieldEntriesByInstanceIdResponse {
21
+ [modelId: string]: CustomFieldEntries;
22
+ }
23
+
24
+ export const getCustomFieldEntriesByInstanceId = async ({
25
+ instancesIds,
26
+ options,
27
+ sadotOptions,
28
+ }: {
29
+ instancesIds: string[],
30
+ options?: { transaction: Transaction },
31
+ sadotOptions: Pick<CustomFieldOptions, 'useCustomFieldsEntries'>,
32
+ }): Promise<GetCustomFieldEntriesByInstanceIdResponse> => {
33
+ if (!sadotOptions.useCustomFieldsEntries) {
34
+ return {};
35
+ }
36
+
37
+ const customFieldEntries = await EntriesRepo.findEntriesByModelIds(
38
+ instancesIds,
39
+ options ?? {},
40
+ );
41
+
42
+ const customFieldEntriesByInstanceId = Object.fromEntries(customFieldEntries.map((instanceEntries) => {
43
+ const { modelId, customFields } = instanceEntries?.dataValues ?? {};
44
+ if (!modelId) {
45
+ return undefined;
46
+ }
47
+ return [modelId, customFields];
48
+ }).filter(Boolean));
49
+
50
+ instancesIds.forEach((instanceId) => {
51
+ customFieldEntriesByInstanceId[instanceId] ??= {};
52
+ });
53
+
54
+ return customFieldEntriesByInstanceId;
55
+ };
56
+
57
+ export const getValuesGroupByInstance = async ({
58
+ instancesIds,
59
+ options,
60
+ sadotOptions,
61
+ }: {
62
+ instancesIds: string[],
63
+ options?: { transaction: Transaction },
64
+ sadotOptions: Pick<CustomFieldOptions, 'useCustomFieldsEntries'>,
65
+ }): Promise<GetValuesGroupByInstanceResponse> => {
66
+ if (sadotOptions.useCustomFieldsEntries) {
67
+ return {};
68
+ }
69
+
70
+ const customFieldValues = await ValueRepo.findValuesByModelIds(
71
+ instancesIds,
72
+ options ?? {},
73
+ );
74
+
75
+ // Group fields by modelId
76
+ return customFieldValues.reduce((acc, v) => {
77
+ const { modelId } = v;
78
+ acc[modelId] ??= [];
79
+ acc[modelId].push(v);
80
+ return acc;
81
+ }, {});
82
+ };
83
+
12
84
  /**
13
85
  * Serialize custom fields value into the format of {[name] -> [fieldData]}
14
86
  */
@@ -33,6 +105,7 @@ const enrichResults = (
33
105
  scopeAttributes: string[],
34
106
  hookType?: SupportedHookTypes,
35
107
  modelOptions: ModelOptions = {},
108
+ sadotOptions: Pick<CustomFieldOptions, 'useCustomFieldsEntries'> = { useCustomFieldsEntries: false },
36
109
  ) => async (
37
110
  instancesOrInstance: any | any[],
38
111
  options,
@@ -90,32 +163,29 @@ const enrichResults = (
90
163
  // Get the values per instates ids:
91
164
  const instancesIds = instances.map((i) => i[primaryKey]);
92
165
 
93
- const customFieldValues = await ValueRepo.findValuesByModelIds(
166
+ // Group fields by modelId
167
+ const valuesGroupByInstance = await getValuesGroupByInstance({
94
168
  instancesIds,
95
- { transaction: options.transaction },
96
- );
169
+ options,
170
+ sadotOptions,
171
+ });
97
172
 
98
- // Group fields by modelId
99
- const valuesGroupByInstance: {
100
- [modelId: string]: CustomFieldValue[];
101
- } = customFieldValues.reduce((acc, v) => {
102
- const { modelId } = v;
103
- if (!acc[modelId]) {
104
- acc[modelId] = [];
105
- }
106
- acc[modelId].push(v);
107
- return acc;
108
- }, {});
173
+ const customFieldEntriesByInstanceId = await getCustomFieldEntriesByInstanceId({
174
+ instancesIds,
175
+ options,
176
+ sadotOptions,
177
+ });
109
178
 
110
179
  // Attach custom fields to the instances
111
180
  instances.forEach((instance) => {
112
- const customFields = {};
113
181
  const { id } = instance;
182
+
114
183
  const instanceValues = valuesGroupByInstance[id];
115
- if (instanceValues) {
116
- const serializedCustomFields = serializeCustomFields(instanceValues, definitionsMap);
117
- Object.assign(customFields, serializedCustomFields);
118
- }
184
+ const serializedCustomFieldsValues = instanceValues ? serializeCustomFields(instanceValues, definitionsMap) : {};
185
+
186
+ const customFields = sadotOptions.useCustomFieldsEntries
187
+ ? customFieldEntriesByInstanceId[id]
188
+ : serializedCustomFieldsValues;
119
189
 
120
190
  scopeAttributes.forEach((attribute) => {
121
191
  const identifier = instance[attribute];
package/src/index.ts CHANGED
@@ -37,7 +37,7 @@ const useCustomFields = async (
37
37
  // The order is important
38
38
  addHooks(models, getModel, { useCustomFieldsEntries });
39
39
  await initTables(sequelize, options.getUser, { useCustomFieldsEntries });
40
- addScopes(models, getModel);
40
+ addScopes(models, getModel, { useCustomFieldsEntries });
41
41
  applyCustomAssociation(models);
42
42
  logger.debug('sadot - custom fields finished initializing with models', models);
43
43
  return sequelize;
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-param-reassign */
1
2
  import type {
2
3
  FindOptions,
3
4
  Includeable,
@@ -9,6 +10,7 @@ import type { ModelOptions } from '../types';
9
10
  import logger from '../utils/logger';
10
11
  import { MissingDefinitionError } from '../errors';
11
12
  import * as DefinitionRepo from './definition';
13
+ import { formatFunctions } from './utils/formatValues';
12
14
 
13
15
  type CustomFieldEntriesModelOptions = ModelOptions & { include?: Includeable, transaction?: Transaction };
14
16
 
@@ -65,6 +67,15 @@ export const updateEntries = async (
65
67
  logger.warn(`custom-fields: trying to update disabled values: ${valuesWithDisabledDefinitions.join(', ')}`);
66
68
  }
67
69
 
70
+ const definitionsByName = Object.fromEntries(fieldDefinitions.map((definition) => [definition.name, definition]));
71
+ // If we need to format the value before we save it
72
+ Object.entries(customFields)
73
+ .filter(([definitionName]) => formatFunctions[definitionsByName[definitionName].fieldType])
74
+ .forEach(([definitionName, value]) => {
75
+ const { fieldType } = definitionsByName[definitionName];
76
+ customFields[definitionName] = formatFunctions[fieldType](value);
77
+ });
78
+
68
79
  return CustomFieldEntries.upsert(
69
80
  {
70
81
  modelId,
@@ -0,0 +1,14 @@
1
+ import { CustomFieldDefinitionType } from '../../utils/constants';
2
+
3
+ export const formatFunctions = {
4
+ [CustomFieldDefinitionType.DATE]: (value) => {
5
+ if (value) {
6
+ const date = new Date(value);
7
+ if (date.toString() === 'Invalid Date') {
8
+ throw new Error(`Invalid date value: ${value}`);
9
+ }
10
+ return date.toISOString();
11
+ }
12
+ return null;
13
+ },
14
+ };
@@ -5,7 +5,7 @@ import type { CreateCustomFieldValue, ValuesToUpdate } from '../types/value';
5
5
  import logger from '../utils/logger';
6
6
  import { MissingDefinitionError } from '../errors';
7
7
  import type { ModelOptions } from '../types';
8
- import { CustomFieldDefinitionType } from '../utils/constants';
8
+ import { formatFunctions } from './utils/formatValues';
9
9
 
10
10
  export const findByModelIdAndDefinition = async (modelId: string, customFieldDefinitionId: string) =>
11
11
  CustomFieldValue.findAll({ where: { modelId, customFieldDefinitionId }, include: [CustomFieldDefinition] });
@@ -42,19 +42,6 @@ export const findValuesByModelIds = async (modelIds: string[], options?): Promis
42
42
  });
43
43
  };
44
44
 
45
- const formatFunctions = {
46
- [CustomFieldDefinitionType.DATE]: (value) => {
47
- if (value) {
48
- const date = new Date(value);
49
- if (date.toString() === 'Invalid Date') {
50
- throw new Error(`Invalid date value: ${value}`);
51
- }
52
- return date.toISOString();
53
- }
54
- return null;
55
- },
56
- };
57
-
58
45
  /**
59
46
  * Try to update custom field values for a model instance.
60
47
  * Create new value record if not exists, but fails if value's definition not exist.
@@ -1,58 +1,26 @@
1
1
  /* eslint-disable import/prefer-default-export */
2
- import { Op, type WhereOptions } from 'sequelize';
2
+ import { Op } from 'sequelize';
3
3
  import { Sequelize } from 'sequelize-typescript';
4
4
  import { customFields } from '@autofleet/common-types';
5
5
  import { generateRandomString } from '../utils/helpers';
6
+ import type { CustomFieldOptions } from '../types';
7
+ import {
8
+ formatConditionsForEntries,
9
+ formatConditionsForValues,
10
+ getFilterCustomFieldsSubQuery,
11
+ getSortCustomFieldsSubQuery,
12
+ SubQueryType,
13
+ type ConditionValue,
14
+ type CustomFieldFilterOptions,
15
+ } from './helpers/filter.helpers';
6
16
 
7
17
  const { CUSTOM_FIELDS_FILTER_SCOPE } = customFields;
8
18
 
9
- /**
10
- * Type representing possible condition values.
11
- * Currently supporting strings and arrays of strings.
12
- * More types to be added (TBA).
13
- */
14
- type ConditionWithOperator = {
15
- operator: string;
16
- value: string;
17
- };
18
- export type ConditionValue = ConditionWithOperator | ConditionWithOperator[] | string | string[];
19
-
20
- export type CustomFieldSort = {
21
- field: string;
22
- direction: 'ASC' | 'DESC';
23
- }
24
-
25
- export type CustomFieldFilterOptions = {
26
- where?: WhereOptions;
27
- replacements?: Record<string, string>;
28
- }
29
-
30
19
  type customFieldsFilterScopeParams = {
31
20
  replacementsMap: Record<string, string>;
32
21
  scopeValue: Record<string, ConditionValue>;
33
22
  }
34
23
 
35
- const isConditionStringArray = (input: any): input is string[] => Array.isArray(input) && typeof input[0] === 'string';
36
- const isBooleanString = (input: string): boolean => ['true', 'false'].includes(input.toString());
37
- const isDate = (input: any): input is Date => input instanceof Date || Object.prototype.toString.call(input) === '[object Date]';
38
-
39
- const castValueToJsonb = (value: string, type: string) => `to_jsonb(${value}::${type})`;
40
- const castValueToJsonbText = (value: string) => castValueToJsonb(value, 'text');
41
- const castValueToJsonbBoolean = (value: string) => castValueToJsonb(value, 'boolean');
42
- const castValueToJsonbNumeric = (value: string) => castValueToJsonb(value, 'numeric');
43
- const castIfNeeded = (columnName: string, conditionValue: string): string => {
44
- if (isDate(conditionValue)) {
45
- return castValueToJsonb(columnName, 'timestamp');
46
- }
47
- return columnName;
48
- };
49
- const AND_DELIMITER = ' AND ';
50
- const OR_DELIMITER = ' OR ';
51
- const CD_TABLE_ALIAS = 'cd';
52
- const CD_NAME_COLUMN = `${CD_TABLE_ALIAS}.name`;
53
- const CV_TABLE_ALIAS = 'cv';
54
- const CV_VALUE_COLUMN = `${CV_TABLE_ALIAS}.value`;
55
-
56
24
  /**
57
25
  * A Sequelize scope for filtering models by custom fields.
58
26
  * This scope builds a WHERE clause to be applied on the main query.
@@ -62,68 +30,29 @@ const CV_VALUE_COLUMN = `${CV_TABLE_ALIAS}.value`;
62
30
  */
63
31
  export const customFieldsFilterScope = (
64
32
  name: string,
33
+ options?: Pick<CustomFieldOptions, 'useCustomFieldsEntries'>,
65
34
  ) => ({ replacementsMap: replacements, scopeValue: conditions }: customFieldsFilterScopeParams): CustomFieldFilterOptions => {
66
35
  if (!conditions || Object.keys(conditions).length === 0) {
67
36
  return {};
68
37
  }
38
+
39
+ const queryType = options?.useCustomFieldsEntries ? SubQueryType.ENTRIES : SubQueryType.VALUES;
69
40
  const reverseReplacementsMap = new Map(Object.entries(replacements).map(([key, value]) => [value, key]));
70
41
  // Build the WHERE clause for custom field filtering
71
42
  const conditionsStrings = Object.entries(conditions).map(([key, condition]) => {
72
- const replacementKey = reverseReplacementsMap.get(key);
73
- if (!replacementKey) return false;
74
- const columnCondition = `(${CD_NAME_COLUMN} = :${replacementKey})`;
75
-
76
- if (Array.isArray(condition)) {
77
- if (condition.length === 0) {
78
- // if empty array, the condition is ignored
43
+ switch (queryType) {
44
+ case SubQueryType.ENTRIES:
45
+ return formatConditionsForEntries(key, condition, reverseReplacementsMap);
46
+ case SubQueryType.VALUES:
47
+ return formatConditionsForValues(key, condition, reverseReplacementsMap);
48
+ default:
79
49
  return false;
80
- }
81
- if (isConditionStringArray(condition)) {
82
- const values = condition.flatMap((v) => {
83
- const valRandom = reverseReplacementsMap.get(v);
84
- if (isBooleanString(v)) {
85
- return [castValueToJsonbText(`:${valRandom}`), castValueToJsonbBoolean(`:${valRandom}`)];
86
- }
87
- if (!Number.isNaN(Number(v))) {
88
- return castValueToJsonbNumeric(`:${valRandom}`);
89
- }
90
- return castValueToJsonbText(`:${valRandom}`);
91
- }).join(',');
92
- return `(${columnCondition}${AND_DELIMITER}${CV_VALUE_COLUMN} IN (${values}))`;
93
- }
94
- return condition.map((c) => {
95
- const valRep = reverseReplacementsMap.get(c.value);
96
- const valueAsJsonb = castValueToJsonbText(`:${valRep}`);
97
- return `(${columnCondition}${AND_DELIMITER}${castIfNeeded(CV_VALUE_COLUMN, c.value)} ${c.operator} ${valueAsJsonb})`;
98
- }).join(AND_DELIMITER);
99
- }
100
- if (typeof condition === 'string' || typeof condition === 'number') {
101
- const conditionRep = reverseReplacementsMap.get(condition);
102
- const valueAsJsonb = !Number.isNaN(Number(condition)) ? castValueToJsonbNumeric(`:${conditionRep}`) : castValueToJsonbText(`:${conditionRep}`);
103
- const valueAsJsonbBoolean = isBooleanString(condition) ? `${OR_DELIMITER}${CV_VALUE_COLUMN} = ${castValueToJsonbBoolean(`:${conditionRep}`)}` : '';
104
- return `(${columnCondition}${AND_DELIMITER}(${castIfNeeded(CV_VALUE_COLUMN, condition)} = ${valueAsJsonb}${valueAsJsonbBoolean}))`;
105
50
  }
106
- if (condition?.operator) {
107
- const valueRep = reverseReplacementsMap.get(condition.value);
108
- const valueAsJsonb = castValueToJsonbText(`:${valueRep}`);
109
- return `( ${columnCondition}${AND_DELIMITER}${castIfNeeded(CV_VALUE_COLUMN, condition.value)} ${condition.operator} ${valueAsJsonb})`;
110
- }
111
- return false;
112
51
  }).filter(Boolean);
113
52
  if (conditionsStrings.length === 0) {
114
53
  return {};
115
54
  }
116
- const customFieldConditions = conditionsStrings.join(OR_DELIMITER);
117
- const subQuery = `
118
- SELECT cv.model_id
119
- FROM custom_field_values AS cv
120
- INNER JOIN custom_field_definitions AS cd ON cv.custom_field_definition_id = cd.id
121
- ${AND_DELIMITER}cd.model_type = '${name}'
122
- WHERE ${customFieldConditions}
123
- ${AND_DELIMITER}cv.deleted_at IS NULL${AND_DELIMITER}cd.deleted_at IS NULL
124
- GROUP BY cv.model_id
125
- HAVING COUNT(DISTINCT cv.custom_field_definition_id) = ${conditionsStrings.length}
126
- `.replace(/\n/g, '');
55
+ const subQuery = getFilterCustomFieldsSubQuery(queryType, name, conditionsStrings);
127
56
 
128
57
  return {
129
58
  where: {
@@ -139,27 +68,21 @@ export const scopeName = CUSTOM_FIELDS_FILTER_SCOPE;
139
68
 
140
69
  export const customFieldsSortScope = (
141
70
  name: string,
71
+ options?: Pick<CustomFieldOptions, 'useCustomFieldsEntries'>,
142
72
  ) => ({ replacementsMap, scopeValue: sort }) => {
143
73
  if (!sort || sort.length === 0) {
144
74
  return {};
145
75
  }
76
+
77
+ const queryType = options?.useCustomFieldsEntries ? SubQueryType.ENTRIES : SubQueryType.VALUES;
146
78
  const randomStr = generateRandomString();
147
79
  const includes = Object.entries(sort).map(([key]) => {
148
- const replacemetKey = Object.keys(replacementsMap).find(
80
+ const replacementKey = Object.keys(replacementsMap).find(
149
81
  (randomString) => replacementsMap[randomString] === key,
150
82
  );
151
83
  return ([
152
- Sequelize.literal(`(
153
- SELECT value
154
- FROM (SELECT cv.model_id, cv.value
155
- FROM custom_field_values AS cv INNER JOIN custom_field_definitions AS cd
156
- ON cv.custom_field_definition_id = cd.id
157
- ${AND_DELIMITER}cd.model_type = '${name}'
158
- WHERE cv.model_id = "${name}"."id"
159
- ${AND_DELIMITER}cd.name = :${replacemetKey}
160
- ) AS CustomFieldAggregation
161
- )
162
- `), randomStr,
84
+ Sequelize.literal(getSortCustomFieldsSubQuery(queryType, name, replacementKey)),
85
+ randomStr,
163
86
  ]);
164
87
  });
165
88