Skip to content

Commit fda1f56

Browse files
authored
feat: 🎸 Add support for joins with filters (#3010)
This commit adds support for being able to join to other tables when filtering. Support was also added for being able to find null values.
1 parent fa18bff commit fda1f56

File tree

3 files changed

+348
-132
lines changed

3 files changed

+348
-132
lines changed

‎addons/api/addon/utils/sqlite-query.js‎

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ export function generateSQLExpressions(
3131
const conditions = [];
3232
const parameters = [];
3333
const tableName = underscore(resource);
34+
const joins = [];
3435

35-
addFilterConditions({ filters, parameters, conditions });
36+
addFilterConditions({ filters, parameters, conditions, joins, tableName });
3637
addSearchConditions({ search, resource, tableName, parameters, conditions });
3738

3839
const selectClause = constructSelectClause(select, tableName);
39-
const orderByClause = constructOrderByClause(resource, sort);
40+
const joinClause = constructJoinClause(joins);
41+
const orderByClause = constructOrderByClause(resource, tableName, sort);
4042
const whereClause = conditions.length ? `WHERE ${and(conditions)}` : '';
4143

4244
const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : '';
@@ -49,6 +51,7 @@ export function generateSQLExpressions(
4951
// This is mainly to help us read and test the generated SQL as it has no effect on the actual SQL execution.
5052
sql: `
5153
${selectClause}
54+
${joinClause}
5255
${whereClause}
5356
${orderByClause}
5457
${paginationClause}`
@@ -58,7 +61,13 @@ export function generateSQLExpressions(
5861
};
5962
}
6063

61-
function addFilterConditions({ filters, parameters, conditions }) {
64+
function addFilterConditions({
65+
filters,
66+
parameters,
67+
conditions,
68+
joins,
69+
tableName,
70+
}) {
6271
if (!filters) {
6372
return;
6473
}
@@ -69,7 +78,7 @@ function addFilterConditions({ filters, parameters, conditions }) {
6978
? filterArrayOrObject
7079
: filterArrayOrObject.values;
7180

72-
if (!filterValueArray || !filterValueArray.length) {
81+
if (!filterValueArray || !filterValueArray.length || key === 'joins') {
7382
continue;
7483
}
7584

@@ -86,8 +95,13 @@ function addFilterConditions({ filters, parameters, conditions }) {
8695
allOperatorsEqual
8796
) {
8897
const operation = firstOperator === 'equals' ? 'in' : 'notIn';
98+
const nullOperator =
99+
firstOperator === 'equals' ? 'IS NULL' : 'IS NOT NULL';
89100
const values = filterValueArray
90-
.filter((f) => f)
101+
.filter(
102+
(filterObjValue) =>
103+
filterObjValue && Object.values(filterObjValue)[0] !== null,
104+
)
91105
.map((filterObjValue) => {
92106
let value = Object.values(filterObjValue)[0];
93107
if (typeOf(value) === 'date') {
@@ -96,7 +110,14 @@ function addFilterConditions({ filters, parameters, conditions }) {
96110
parameters.push(value);
97111
return value;
98112
});
99-
const filterCondition = `${key}${OPERATORS[operation](values)}`;
113+
114+
// Add a check for null values as an IN clause will mistakenly use `=` for null values.
115+
// We'll add an extra OR clause to handle nulls separately.
116+
const isNullValue = filterValueArray.some(
117+
(filterObjValue) => Object.values(filterObjValue)[0] === null,
118+
);
119+
120+
const filterCondition = `"${tableName}".${key}${OPERATORS[operation](values)}${isNullValue ? ` OR "${tableName}".${key} ${nullOperator}` : ''}`;
100121
conditions.push(parenthetical(filterCondition));
101122
continue;
102123
}
@@ -106,6 +127,16 @@ function addFilterConditions({ filters, parameters, conditions }) {
106127
.map((filterObjValue) => {
107128
let [operation, value] = Object.entries(filterObjValue)[0];
108129

130+
// Handle null values: convert equals/notEquals to IS NULL or IS NOT NULL
131+
if (value === null) {
132+
if (operation === 'equals') {
133+
return `"${tableName}".${key} IS NULL`;
134+
}
135+
if (operation === 'notEquals') {
136+
return `"${tableName}".${key} IS NOT NULL`;
137+
}
138+
}
139+
109140
// SQLite needs to be working with ISO strings
110141
if (typeOf(value) === 'date') {
111142
value = value.toISOString();
@@ -118,7 +149,7 @@ function addFilterConditions({ filters, parameters, conditions }) {
118149
parameters.push(value);
119150
}
120151

121-
return `${key}${OPERATORS[operation]}`;
152+
return `"${tableName}".${key}${OPERATORS[operation]}`;
122153
});
123154

124155
const { logicalOperator } = filterArrayOrObject;
@@ -131,6 +162,43 @@ function addFilterConditions({ filters, parameters, conditions }) {
131162
),
132163
);
133164
}
165+
166+
if (filters.joins?.length > 0) {
167+
const tableNameIndex = {};
168+
169+
filters.joins.forEach((join) => {
170+
const {
171+
resource,
172+
query,
173+
joinFrom = 'id',
174+
joinOn,
175+
joinType = 'INNER',
176+
} = join;
177+
const joinTableName = underscore(resource);
178+
tableNameIndex[resource] ??= 1;
179+
// Alias in the possible scenario we join the same table more than once
180+
const alias = `${joinTableName}${tableNameIndex[resource]}`;
181+
182+
joins.push({
183+
type: joinType,
184+
table: joinTableName,
185+
alias,
186+
condition: `"${tableName}".${joinFrom} = ${alias}.${joinOn}`,
187+
});
188+
189+
// Add the conditions from the join query
190+
if (query?.filters) {
191+
addFilterConditions({
192+
filters: query.filters,
193+
parameters,
194+
conditions,
195+
tableName: alias,
196+
});
197+
}
198+
199+
tableNameIndex[resource]++;
200+
});
201+
}
134202
}
135203

136204
function addSearchConditions({
@@ -161,7 +229,7 @@ function addSearchConditions({
161229
// much more efficient with FTS queries when using rowids or MATCH (or both).
162230
// We could have also used a join here but a subquery is simpler.
163231
conditions.push(
164-
`rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
232+
`"${tableName}".rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
165233
);
166234
}
167235

@@ -170,7 +238,7 @@ function constructSelectClause(select = [{ field: '*' }], tableName) {
170238
const nonDistinctColumns = select.filter(({ isDistinct }) => !isDistinct);
171239

172240
const buildColumnExpression = ({ field, isCount, alias, isDistinct }) => {
173-
let column = field;
241+
let column = field === '*' ? field : `"${tableName}".${field}`;
174242

175243
// If there's just distinct with nothing else, this will be handled separately
176244
// and we will just return the column back as is
@@ -219,11 +287,27 @@ function constructSelectClause(select = [{ field: '*' }], tableName) {
219287
return `SELECT ${selectColumns} FROM "${tableName}"`;
220288
}
221289

222-
function constructOrderByClause(resource, sort) {
223-
const defaultOrderByClause = 'ORDER BY created_time DESC';
290+
function constructJoinClause(joins) {
291+
if (!joins || joins.length === 0) {
292+
return '';
293+
}
294+
295+
return joins
296+
.map(
297+
({ type, table, alias, condition }) =>
298+
`${type} JOIN "${table}" ${alias} ON ${condition}`,
299+
)
300+
.join(' ');
301+
}
302+
303+
function constructOrderByClause(resource, tableName, sort) {
304+
const defaultOrderByClause = `ORDER BY "${tableName}".created_time DESC`;
224305

225306
const { attributes, customSort, direction, isCoalesced } = sort;
226307
const sortDirection = direction === 'desc' ? 'DESC' : 'ASC';
308+
const attributesWithTableName = attributes?.map(
309+
(attribute) => `"${tableName}".${attribute}`,
310+
);
227311

228312
// We have to check if the attributes are valid for the resource
229313
// as we can't use parameterized queries for ORDER BY
@@ -239,21 +323,21 @@ function constructOrderByClause(resource, sort) {
239323
},
240324
'',
241325
);
242-
return `ORDER BY CASE ${attributes.join(', ')} ${whenClauses}END ${sortDirection}`;
326+
return `ORDER BY CASE ${attributesWithTableName.join(', ')} ${whenClauses}END ${sortDirection}`;
243327
} else if (attributes?.length > 0) {
244-
const commaSeparatedVals = attributes.join(', ');
328+
const commaSeparatedVals = attributesWithTableName.join(', ');
245329

246330
// In places where `collate nocase` is used, it is to ensure case is ignored on the initial sort.
247331
// Then, a sort on the same condition is performed to ensure upper-case strings are given preference in a tie.
248332
if (isCoalesced) {
249-
return `ORDER BY COALESCE(${attributes.join(', ')}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
333+
return `ORDER BY COALESCE(${commaSeparatedVals}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
250334
}
251335

252336
const attributesWithNoCollate = attributes
253-
.map((attr) => `${attr} COLLATE NOCASE ${sortDirection}`)
337+
.map((attr) => `"${tableName}".${attr} COLLATE NOCASE ${sortDirection}`)
254338
.join(', ');
255339
const attributesWithDirection = attributes
256-
.map((attr) => `${attr} ${sortDirection}`)
340+
.map((attr) => `"${tableName}".${attr} ${sortDirection}`)
257341
.join(', ');
258342
return `ORDER BY ${attributesWithNoCollate}, ${attributesWithDirection}`;
259343
} else if (modelMapping[resource]?.created_time) {

0 commit comments

Comments
 (0)