rimecms 0.23.27 → 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,154 +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');
96
97
  }
97
- // Not a relation
98
- if (!isRelationField(fieldConfig)) {
99
- logger.warn(`the query contains the field "${column}", not found for ${documentConfig.slug} document`);
100
- // 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
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();
102
145
  }
103
- // Unsupported operator for multi-valued relations
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 :
104
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.
105
153
  if (fieldConfig.many && !supportedRelationManyOperators.includes(operator)) {
106
- logger.warn(`the operator "${operator}" is not supported for multi-valued relation field "${column}" in ${documentConfig.slug} document`);
154
+ const unsupportedMessage = `the operator "${operator}" is not supported for multi-valued relation field "${column}" in ${documentConfig.slug} document`;
155
+ logger.warn(unsupportedMessage);
107
156
  // Return a condition that will always be false
108
- return eq(table.id, '-1'); // No document will have ID = -1, so this will always be false
157
+ return eq(table.id, '-1');
109
158
  }
110
- // Only compare with the relation ID for now
111
- // @TODO handle relation props ex: author.email
159
+ // Build relation condition
112
160
  const [to, localized] = [fieldConfig.relationTo, fieldConfig.localized];
113
- const relationTableName = `${slug}Rels`;
114
- 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));
115
191
  // Handle multi-valued relations specially when operator is `equals` to provide
116
192
  // strict equality semantics (the relation set must equal the provided value(s)).
117
193
  if (fieldConfig.many && operator === 'equals') {
118
- // Accept array inputs, repeated params, or CSV values for equality checks
119
- let values = (() => {
120
- if (Array.isArray(rawValue))
121
- return rawValue;
122
- if (typeof rawValue === 'string' && rawValue.includes(','))
123
- return rawValue.split(',');
124
- if (Array.isArray(value))
125
- return value;
126
- return [value];
127
- })();
128
- // Ensure values are unique
129
- values = Array.from(new Set(values));
194
+ // Accept various input forms and normalize to a unique array
195
+ const values = normalizeValues(rawValue, value);
130
196
  // Owners with total relation count equal to values.length
131
- const ownersWithTotalCount = db
132
- .select({ id: relationTable.ownerId })
133
- .from(relationTable)
134
- .where(and(eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
135
- .groupBy(relationTable.ownerId)
136
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
197
+ const ownersWithTotalCount = buildOwnersWithTotalCount(values.length);
137
198
  // Owners with matching relations count equal to values.length
138
- const ownersWithMatchingCount = db
139
- .select({ id: relationTable.ownerId })
140
- .from(relationTable)
141
- .where(and(drizzleORM.inArray(relationTable[`${to}Id`], values), eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
142
- .groupBy(relationTable.ownerId)
143
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
199
+ const ownersWithMatchingCount = buildOwnersWithMatchingCount(values);
144
200
  return and(inArray(table.id, ownersWithTotalCount), inArray(table.id, ownersWithMatchingCount));
145
201
  }
146
202
  // For multi-valued relations, allow `in_array` to act as a subset check:
147
203
  // The provided values must contain ALL relation values of the document.
148
204
  if (fieldConfig.many && operator === 'in_array') {
149
- // Accept array inputs, repeated params, or CSV values for in_array checks
150
- let values = (() => {
151
- if (Array.isArray(rawValue))
152
- return rawValue;
153
- if (typeof rawValue === 'string' && rawValue.includes(','))
154
- return rawValue.split(',');
155
- if (Array.isArray(value))
156
- return value;
157
- return [value];
158
- })();
159
- // Ensure values are unique
160
- values = Array.from(new Set(values));
205
+ // Accept various input forms and normalize to a unique array
206
+ const values = normalizeValues(rawValue, value);
161
207
  // Owners that have at least one relation row not included in the provided set
162
- const ownersWithNonMatching = db
163
- .select({ id: relationTable.ownerId })
164
- .from(relationTable)
165
- .where(and(drizzleORM.notInArray(relationTable[`${to}Id`], values), eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
166
- .groupBy(relationTable.ownerId)
167
- .having(drizzleORM.gt(drizzleORM.count(relationTable.id), 0));
208
+ const ownersWithNonMatching = buildOwnersWithNonMatching(values);
168
209
  // Owners that have any relation rows (to exclude docs with no relations)
169
- const ownersWithRelations = db
170
- .select({ id: relationTable.ownerId })
171
- .from(relationTable)
172
- .where(and(eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
173
- .groupBy(relationTable.ownerId)
174
- .having(drizzleORM.gt(drizzleORM.count(relationTable.id), 0));
210
+ const ownersWithRelations = buildOwnersWithRelations();
175
211
  // Match documents that have relations and do NOT have any non-matching relation rows
176
212
  return and(drizzleORM.notInArray(table.id, ownersWithNonMatching), inArray(table.id, ownersWithRelations));
177
213
  }
178
214
  // For multi-valued relations, `not_in_array` should match documents where the provided
179
215
  // set does NOT contain all of the document's relation values (inverse of `in_array`).
180
216
  if (fieldConfig.many && operator === 'not_in_array') {
181
- let values = (() => {
182
- if (Array.isArray(rawValue))
183
- return rawValue;
184
- if (typeof rawValue === 'string' && rawValue.includes(','))
185
- return rawValue.split(',');
186
- if (Array.isArray(value))
187
- return value;
188
- return [value];
189
- })();
190
- values = Array.from(new Set(values));
191
- const ownersWithNonMatching = db
192
- .select({ id: relationTable.ownerId })
193
- .from(relationTable)
194
- .where(and(drizzleORM.notInArray(relationTable[`${to}Id`], values), eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
195
- .groupBy(relationTable.ownerId)
196
- .having(drizzleORM.gt(drizzleORM.count(relationTable.id), 0));
217
+ // Accept various input forms and normalize to a unique array
218
+ const values = normalizeValues(rawValue, value);
219
+ const ownersWithNonMatching = buildOwnersWithNonMatching(values);
197
220
  return inArray(table.id, ownersWithNonMatching);
198
221
  }
199
222
  // For multi-valued relations, `not_equals` is the inverse of `equals` (exact-set inequality)
200
223
  if (fieldConfig.many && operator === 'not_equals') {
201
- let values = (() => {
202
- if (Array.isArray(rawValue))
203
- return rawValue;
204
- if (typeof rawValue === 'string' && rawValue.includes(','))
205
- return rawValue.split(',');
206
- if (Array.isArray(value))
207
- return value;
208
- return [value];
209
- })();
210
- values = Array.from(new Set(values));
211
- const ownersWithTotalCount = db
212
- .select({ id: relationTable.ownerId })
213
- .from(relationTable)
214
- .where(and(eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
215
- .groupBy(relationTable.ownerId)
216
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
217
- const ownersWithMatchingCount = db
218
- .select({ id: relationTable.ownerId })
219
- .from(relationTable)
220
- .where(and(inArray(relationTable[`${to}Id`], values), eq(relationTable.path, column), ...(localized ? [eq(relationTable.locale, locale)] : [])))
221
- .groupBy(relationTable.ownerId)
222
- .having(drizzleORM.eq(drizzleORM.count(relationTable.id), values.length));
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);
223
228
  return or(drizzleORM.notInArray(table.id, ownersWithTotalCount), drizzleORM.notInArray(table.id, ownersWithMatchingCount));
224
229
  }
230
+ // Handle single-valued relations and other operators
225
231
  // Default behavior (membership checks with in_array etc.)
226
- const conditions = [eq(relationTable.path, column), fn(relationTable[`${to}Id`], value)];
227
- if (localized) {
228
- conditions.push(eq(relationTable.locale, locale));
229
- }
232
+ const conditions = [
233
+ eq(relsTable.path, column),
234
+ fn(relsTable[`${to}Id`], value),
235
+ ...(localized ? [eq(relsTable.locale, locale)] : [])
236
+ ];
230
237
  return inArray(table.id, db
231
- .select({ id: relationTable.ownerId })
232
- .from(relationTable)
238
+ .select({ id: relsTable.ownerId })
239
+ .from(relsTable)
233
240
  .where(and(...conditions)));
234
241
  };
235
242
  return buildCondition(query.where);
@@ -252,6 +259,7 @@ const operators = {
252
259
  greater_than: drizzleORM.gt
253
260
  };
254
261
  const isOperator = (str) => Object.keys(operators).includes(str);
262
+ // Format value based on operator and type
255
263
  const formatValue = ({ operator, value }) => {
256
264
  switch (true) {
257
265
  case typeof value === 'string' &&
@@ -269,3 +277,43 @@ const formatValue = ({ operator, value }) => {
269
277
  return value;
270
278
  }
271
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.27",
3
+ "version": "0.24.0",
4
4
  "homepage": "https://github.com/bienbiendev/rime",
5
5
  "scripts": {
6
6
  "dev": "vite dev",