rimecms 0.23.26 → 0.24.0

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.
@@ -7,61 +7,40 @@ import {} from '../index.js';
7
7
  import * as drizzleORM from 'drizzle-orm';
8
8
  import { and, eq, getTableColumns, inArray, or } from 'drizzle-orm';
9
9
  export const buildWhereParam = ({ query, slug, db, locale, tables, configCtx }) => {
10
+ // Helper to get table by key with correct typing
10
11
  function getTable(key) {
11
12
  return tables[key];
12
13
  }
13
- const table = getTable(slug);
14
- const tableNameLocales = withLocalesSuffix(slug);
15
- const tableLocales = getTable(tableNameLocales);
16
- const localizedColumns = locale && tableNameLocales in tables ? Object.keys(getTableColumns(tableLocales)) : [];
17
- const unlocalizedColumns = Object.keys(getTableColumns(table));
14
+ function getTablesAndColumns(slug) {
15
+ // Get main table and localized table if applicable
16
+ const table = getTable(slug);
17
+ const tableNameLocales = withLocalesSuffix(slug);
18
+ const tableLocales = getTable(tableNameLocales);
19
+ // Get localized and unlocalized columns
20
+ const localizedColumns = locale && tableNameLocales in tables ? Object.keys(getTableColumns(tableLocales)) : [];
21
+ const unlocalizedColumns = Object.keys(getTableColumns(table));
22
+ return { table, tableLocales, localizedColumns, unlocalizedColumns };
23
+ }
24
+ const {
25
+ // Get main table and localized table if applicable
26
+ table, tableLocales, localizedColumns, unlocalizedColumns } = getTablesAndColumns(slug);
18
27
  const buildCondition = (conditionObject) => {
19
28
  // Handle nested AND conditions
20
29
  if ('and' in conditionObject && Array.isArray(conditionObject.and)) {
21
- const subConditions = conditionObject.and
22
- .map((condition) => buildCondition(condition))
23
- .filter(Boolean);
30
+ const subConditions = conditionObject.and.map((condition) => buildCondition(condition)).filter(Boolean);
24
31
  return subConditions.length ? and(...subConditions) : false;
25
32
  }
26
33
  // Handle nested OR conditions
27
34
  if ('or' in conditionObject && Array.isArray(conditionObject.or)) {
28
- const subConditions = conditionObject.or
29
- .map((condition) => buildCondition(condition))
30
- .filter(Boolean);
35
+ const subConditions = conditionObject.or.map((condition) => buildCondition(condition)).filter(Boolean);
31
36
  return subConditions.length ? or(...subConditions) : false;
32
37
  }
33
- // Handle id field for versioned collections
34
- // if "id" inside the query it should refer to the root table
35
- if (hasVersionsSuffix(slug) && 'id' in conditionObject) {
36
- // Replace id with ownerId and keep the same operator and value
37
- const idOperator = conditionObject.id;
38
- delete conditionObject.id;
39
- conditionObject.ownerId = idOperator;
40
- }
41
- // Handle versionId field for versioned collections
42
- // if "versionId" inside the query it should refer to the id of the version table (current)
43
- if (hasVersionsSuffix(slug) && 'versionId' in conditionObject) {
44
- // Replace id with ownerId and keep the same operator and value
45
- const idOperator = conditionObject.versionId;
46
- delete conditionObject.versionId;
47
- conditionObject.id = idOperator;
48
- }
49
- // Handle regular field conditions
50
- const [column, operatorObj] = Object.entries(conditionObject)[0];
51
- const [operator, rawValue] = Object.entries(operatorObj)[0];
52
- if (!isOperator(operator)) {
53
- throw new RimeError(RimeError.INVALID_DATA, operator + 'is not supported');
54
- }
55
- // get the correct Drizzle operator
56
- const fn = operators[operator];
57
- // Format compared value to support Date, Arrays,...
58
- const value = formatValue({ operator, value: rawValue });
59
- // Convert dot notation to double underscore
60
- // for fields included in groups or tabs
61
- const sqlColumn = column.replace(/\./g, '__');
38
+ conditionObject = normalizedForVersion(conditionObject, slug);
39
+ const {
40
+ // Get condition members
41
+ column, sqlColumn, fn, operator, rawValue, value } = getConditionMembers(conditionObject);
62
42
  // Handle hierarchy fields (_parent, _position) in versioned collections
63
- if (hasVersionsSuffix(slug) &&
64
- (sqlColumn === '_parent' || sqlColumn === '_position' || sqlColumn === '_path')) {
43
+ if (shouldHandleVersionedHierarchyFields(slug, sqlColumn)) {
65
44
  // Get the root table name by removing the '_versions' suffix
66
45
  const rootSlug = slug.replace('_versions', '');
67
46
  const rootTable = getTable(rootSlug);
@@ -82,64 +61,182 @@ export const buildWhereParam = ({ query, slug, db, locale, tables, configCtx })
82
61
  // Look for a relation field
83
62
  // Get document config
84
63
  const documentConfig = configCtx.getBySlug(slug);
85
- if (!documentConfig) {
86
- throw new Error(`${slug} not found (should never happen)`);
87
- }
88
- // Get the field config
89
- const fieldConfig = getFieldConfigByPath(column, documentConfig.fields.map((f) => f.compile()));
64
+ // Get the compiled fields for lookups
65
+ const compiledFields = documentConfig.fields.map((f) => f.compile());
66
+ let fieldConfig = getFieldConfigByPath(column, compiledFields);
67
+ // Track relation-property detection (e.g. attributes.author.name)
68
+ let matchedPrefix = column;
69
+ let relationPropertyPath = null;
70
+ let isRelationProperty = false;
71
+ // If the exact path isn't found, try progressively shorter prefixes to support
72
+ // relation property queries like `attributes.author.name` -> `attributes.author`
90
73
  if (!fieldConfig) {
91
- // @TODO handle relation props ex: author.email
92
- logger.warn(`the query contains the field "${column}", not found for ${documentConfig.slug} document`);
74
+ const parts = column.split('.');
75
+ for (let i = parts.length - 1; i > 0; i--) {
76
+ const prefix = parts.slice(0, i).join('.');
77
+ const candidate = getFieldConfigByPath(prefix, compiledFields);
78
+ if (candidate) {
79
+ fieldConfig = candidate;
80
+ // If prefix shorter than original column, record the property suffix
81
+ if (i < parts.length) {
82
+ matchedPrefix = prefix;
83
+ relationPropertyPath = parts.slice(i).join('.');
84
+ isRelationProperty = true;
85
+ }
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ // If still not found or not a relation field, log warning and return false condition
91
+ if (!fieldConfig || !isRelationField(fieldConfig)) {
92
+ const message = `the query contains the field "${column}", not found for ${documentConfig.slug} document`;
93
+ logger.warn(message);
93
94
  // Return a condition that will always be false instead of returning false
94
95
  // This ensures no documents match when a non-existent field is queried
95
- return eq(table.id, '-1'); // No document will have ID = -1, so this will always be false
96
+ return eq(table.id, '-1');
97
+ }
98
+ // Helper: normalize various input forms (rawValue, CSV string, or formatted array)
99
+ // into a unique array of values used for multi-valued relation comparisons.
100
+ const normalizeValues = (raw, formatted) => {
101
+ let out;
102
+ if (Array.isArray(raw))
103
+ out = raw;
104
+ else if (typeof raw === 'string' && raw.includes(','))
105
+ out = raw.split(',');
106
+ else if (Array.isArray(formatted))
107
+ out = formatted;
108
+ else
109
+ out = [formatted];
110
+ return Array.from(new Set(out));
111
+ };
112
+ // Handle relation property queries (e.g., attributes.author.name)
113
+ // by building a subquery on the related collection
114
+ const buildRelationPropertyCondition = () => {
115
+ const relatedSlug = fieldConfig.relationTo;
116
+ const relatedTable = getTable(relatedSlug);
117
+ // Build a where clause for the related collection using the same operator/value
118
+ const relatedWhere = { [relationPropertyPath]: { [operator]: rawValue } };
119
+ // Recursive call to buildWhereParam for the related collection
120
+ const relatedCondition = buildWhereParam({
121
+ query: { where: relatedWhere },
122
+ slug: relatedSlug,
123
+ db,
124
+ locale,
125
+ tables,
126
+ configCtx
127
+ });
128
+ if (!relatedCondition) {
129
+ // No documents in related collection match => no parent documents match
130
+ return eq(table.id, '-1');
131
+ }
132
+ // Subquery of related document ids that match the property condition
133
+ const matchingRelatedIds = db.select({ id: relatedTable.id }).from(relatedTable).where(relatedCondition);
134
+ const relsTable = getTable(`${slug}Rels`);
135
+ // Join relation rows to documents by matching the related id and the relation path
136
+ const ownersWithMatching = db
137
+ .select({ id: relsTable.ownerId })
138
+ .from(relsTable)
139
+ .where(and(inArray(relsTable[`${relatedSlug}Id`], matchingRelatedIds), eq(relsTable.path, matchedPrefix), ...(fieldConfig.localized ? [eq(relsTable.locale, locale)] : [])));
140
+ return inArray(table.id, ownersWithMatching);
141
+ };
142
+ // If this is a relation property query, delegate to the relation property handler
143
+ if (isRelationProperty && relationPropertyPath) {
144
+ return buildRelationPropertyCondition();
96
145
  }
97
- // Not a relation
98
- if (!isRelationField(fieldConfig)) {
99
- logger.warn(`the query contains the field "${column}", not found for ${documentConfig.slug} document`);
146
+ // Handle direct relation field queries (e.g., attributes.author)
147
+ // only support a subset of operators for multi-valued relations
148
+ // Unsupported operator for multi-valued relations :
149
+ const supportedRelationManyOperators = ['equals', 'not_equals', 'in_array', 'not_in_array'];
150
+ // Only enforce the restriction for direct relation field queries.
151
+ // If this is a relation property query (e.g. attributes.author.name) we allow
152
+ // any operator because those will be applied to the related collection.
153
+ if (fieldConfig.many && !supportedRelationManyOperators.includes(operator)) {
154
+ const unsupportedMessage = `the operator "${operator}" is not supported for multi-valued relation field "${column}" in ${documentConfig.slug} document`;
155
+ logger.warn(unsupportedMessage);
100
156
  // Return a condition that will always be false
101
- return eq(table.id, '-1'); // No document will have ID = -1, so this will always be false
157
+ return eq(table.id, '-1');
102
158
  }
103
- // Only compare with the relation ID for now
104
- // @TODO handle relation props ex: author.email
159
+ // Build relation condition
105
160
  const [to, localized] = [fieldConfig.relationTo, fieldConfig.localized];
106
- const relationTableName = `${slug}Rels`;
107
- const relationTable = getTable(relationTableName);
161
+ const relsTableName = `${slug}Rels`;
162
+ const relsTable = getTable(relsTableName);
163
+ // Helpers for building common relation-owner subqueries (operate on the relations table)
164
+ const buildOwnersWithTotalCount = (count) => db
165
+ .select({ id: relsTable.ownerId })
166
+ .from(relsTable)
167
+ .where(and(eq(relsTable.path, column), ...(localized ? [eq(relsTable.locale, locale)] : [])))
168
+ .groupBy(relsTable.ownerId)
169
+ .having(drizzleORM.eq(drizzleORM.count(relsTable.id), count));
170
+ // Owners that have total relation count equal to the number of provided values
171
+ const buildOwnersWithMatchingCount = (values) => db
172
+ .select({ id: relsTable.ownerId })
173
+ .from(relsTable)
174
+ .where(and(drizzleORM.inArray(relsTable[`${to}Id`], values), eq(relsTable.path, column), ...(localized ? [eq(relsTable.locale, locale)] : [])))
175
+ .groupBy(relsTable.ownerId)
176
+ .having(drizzleORM.eq(drizzleORM.count(relsTable.id), values.length));
177
+ // Owners that have at least one relation row NOT in the provided values
178
+ const buildOwnersWithNonMatching = (values) => db
179
+ .select({ id: relsTable.ownerId })
180
+ .from(relsTable)
181
+ .where(and(drizzleORM.notInArray(relsTable[`${to}Id`], values), eq(relsTable.path, column), ...(localized ? [eq(relsTable.locale, locale)] : [])))
182
+ .groupBy(relsTable.ownerId)
183
+ .having(drizzleORM.gt(drizzleORM.count(relsTable.id), 0));
184
+ // Owners that have any relation rows (to exclude docs with no relations)
185
+ const buildOwnersWithRelations = () => db
186
+ .select({ id: relsTable.ownerId })
187
+ .from(relsTable)
188
+ .where(and(eq(relsTable.path, column), ...(localized ? [eq(relsTable.locale, locale)] : [])))
189
+ .groupBy(relsTable.ownerId)
190
+ .having(drizzleORM.gt(drizzleORM.count(relsTable.id), 0));
108
191
  // Handle multi-valued relations specially when operator is `equals` to provide
109
192
  // strict equality semantics (the relation set must equal the provided value(s)).
110
193
  if (fieldConfig.many && operator === 'equals') {
111
- // Accept array inputs, repeated params, or CSV values for equality checks
112
- let values = Array.isArray(rawValue)
113
- ? rawValue
114
- : typeof rawValue === 'string' && rawValue.includes(',')
115
- ? rawValue.split(',')
116
- : [value];
117
- // Ensure values are unique
118
- values = Array.from(new Set(values));
194
+ // Accept various input forms and normalize to a unique array
195
+ const values = normalizeValues(rawValue, value);
119
196
  // Owners with total relation count equal to values.length
120
- const ownersWithTotalCount = db
121
- .select({ id: relationTable.ownerId })
122
- .from(relationTable)
123
- .where(and(...(localized ? [eq(relationTable.locale, locale)] : [])))
124
- .groupBy(relationTable.ownerId)
125
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
197
+ const ownersWithTotalCount = buildOwnersWithTotalCount(values.length);
126
198
  // Owners with matching relations count equal to values.length
127
- const ownersWithMatchingCount = db
128
- .select({ id: relationTable.ownerId })
129
- .from(relationTable)
130
- .where(and(drizzleORM.inArray(relationTable[`${to}Id`], values), ...(localized ? [eq(relationTable.locale, locale)] : [])))
131
- .groupBy(relationTable.ownerId)
132
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
199
+ const ownersWithMatchingCount = buildOwnersWithMatchingCount(values);
133
200
  return and(inArray(table.id, ownersWithTotalCount), inArray(table.id, ownersWithMatchingCount));
134
201
  }
135
- // Default behavior (membership checks with in_array etc.)
136
- const conditions = [fn(relationTable[`${to}Id`], value)];
137
- if (localized) {
138
- conditions.push(eq(relationTable.locale, locale));
202
+ // For multi-valued relations, allow `in_array` to act as a subset check:
203
+ // The provided values must contain ALL relation values of the document.
204
+ if (fieldConfig.many && operator === 'in_array') {
205
+ // Accept various input forms and normalize to a unique array
206
+ const values = normalizeValues(rawValue, value);
207
+ // Owners that have at least one relation row not included in the provided set
208
+ const ownersWithNonMatching = buildOwnersWithNonMatching(values);
209
+ // Owners that have any relation rows (to exclude docs with no relations)
210
+ const ownersWithRelations = buildOwnersWithRelations();
211
+ // Match documents that have relations and do NOT have any non-matching relation rows
212
+ return and(drizzleORM.notInArray(table.id, ownersWithNonMatching), inArray(table.id, ownersWithRelations));
213
+ }
214
+ // For multi-valued relations, `not_in_array` should match documents where the provided
215
+ // set does NOT contain all of the document's relation values (inverse of `in_array`).
216
+ if (fieldConfig.many && operator === 'not_in_array') {
217
+ // Accept various input forms and normalize to a unique array
218
+ const values = normalizeValues(rawValue, value);
219
+ const ownersWithNonMatching = buildOwnersWithNonMatching(values);
220
+ return inArray(table.id, ownersWithNonMatching);
139
221
  }
222
+ // For multi-valued relations, `not_equals` is the inverse of `equals` (exact-set inequality)
223
+ if (fieldConfig.many && operator === 'not_equals') {
224
+ // Accept various input forms and normalize to a unique array
225
+ const values = normalizeValues(rawValue, value);
226
+ const ownersWithTotalCount = buildOwnersWithTotalCount(values.length);
227
+ const ownersWithMatchingCount = buildOwnersWithMatchingCount(values);
228
+ return or(drizzleORM.notInArray(table.id, ownersWithTotalCount), drizzleORM.notInArray(table.id, ownersWithMatchingCount));
229
+ }
230
+ // Handle single-valued relations and other operators
231
+ // Default behavior (membership checks with in_array etc.)
232
+ const conditions = [
233
+ eq(relsTable.path, column),
234
+ fn(relsTable[`${to}Id`], value),
235
+ ...(localized ? [eq(relsTable.locale, locale)] : [])
236
+ ];
140
237
  return inArray(table.id, db
141
- .select({ id: relationTable.ownerId })
142
- .from(relationTable)
238
+ .select({ id: relsTable.ownerId })
239
+ .from(relsTable)
143
240
  .where(and(...conditions)));
144
241
  };
145
242
  return buildCondition(query.where);
@@ -162,6 +259,7 @@ const operators = {
162
259
  greater_than: drizzleORM.gt
163
260
  };
164
261
  const isOperator = (str) => Object.keys(operators).includes(str);
262
+ // Format value based on operator and type
165
263
  const formatValue = ({ operator, value }) => {
166
264
  switch (true) {
167
265
  case typeof value === 'string' &&
@@ -179,3 +277,43 @@ const formatValue = ({ operator, value }) => {
179
277
  return value;
180
278
  }
181
279
  };
280
+ // Extract column, operator, and raw value from condition object
281
+ function getConditionMembers(obj) {
282
+ const [column, operatorObj] = Object.entries(obj)[0];
283
+ const [operator, rawValue] = Object.entries(operatorObj)[0];
284
+ if (!isOperator(operator)) {
285
+ throw new RimeError(RimeError.INVALID_DATA, operator + 'is not supported');
286
+ }
287
+ // get the correct Drizzle operator
288
+ const fn = operators[operator];
289
+ // Convert dot notation to double underscore
290
+ // for fields included in groups or tabs
291
+ const sqlColumn = column.replace(/\./g, '__');
292
+ // Format compared value to support Date, Arrays,...
293
+ const value = formatValue({ operator, value: rawValue });
294
+ return { column, sqlColumn, fn, operator, rawValue, value };
295
+ }
296
+ // Determine if we should handle versioned hierarchy fields
297
+ function shouldHandleVersionedHierarchyFields(slug, sqlColumn) {
298
+ return hasVersionsSuffix(slug) && (sqlColumn === '_parent' || sqlColumn === '_position' || sqlColumn === '_path');
299
+ }
300
+ // Normalize condition object for versioned collections
301
+ function normalizedForVersion(conditionObject, slug) {
302
+ // Handle id field for versioned collections
303
+ // if "id" inside the query it should refer to the root table
304
+ if (hasVersionsSuffix(slug) && 'id' in conditionObject) {
305
+ // Replace id with ownerId and keep the same operator and value
306
+ const idOperator = conditionObject.id;
307
+ delete conditionObject.id;
308
+ conditionObject.ownerId = idOperator;
309
+ }
310
+ // Handle versionId field for versioned collections
311
+ // if "versionId" inside the query it should refer to the id of the version table (current)
312
+ if (hasVersionsSuffix(slug) && 'versionId' in conditionObject) {
313
+ // Replace id with ownerId and keep the same operator and value
314
+ const idOperator = conditionObject.versionId;
315
+ delete conditionObject.versionId;
316
+ conditionObject.id = idOperator;
317
+ }
318
+ return conditionObject;
319
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rimecms",
3
- "version": "0.23.26",
3
+ "version": "0.24.0",
4
4
  "homepage": "https://github.com/bienbiendev/rime",
5
5
  "scripts": {
6
6
  "dev": "vite dev",