Skip to content

Commit c84ad0b

Browse files
committed
feat: 🎸 Add support for joins with filters
1 parent 9089203 commit c84ad0b

File tree

3 files changed

+292
-154
lines changed

3 files changed

+292
-154
lines changed

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

Lines changed: 94 additions & 26 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 || key === 'subqueries') {
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;
@@ -132,19 +163,40 @@ function addFilterConditions({ filters, parameters, conditions }) {
132163
);
133164
}
134165

135-
if (filters.subqueries?.length > 0) {
136-
filters.subqueries.forEach((subquery) => {
137-
const { resource, query, select } = subquery;
138-
const { sql, parameters: subqueryParams } = generateSQLExpressions(
166+
if (filters.joins?.length > 0) {
167+
const tableNameIndex = {};
168+
169+
filters.joins.forEach((join) => {
170+
const {
139171
resource,
140172
query,
141-
{
142-
select,
143-
},
144-
);
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+
}
145198

146-
conditions.push(`id IN (${sql})`);
147-
parameters.push(...subqueryParams);
199+
tableNameIndex[resource]++;
148200
});
149201
}
150202
}
@@ -177,7 +229,7 @@ function addSearchConditions({
177229
// much more efficient with FTS queries when using rowids or MATCH (or both).
178230
// We could have also used a join here but a subquery is simpler.
179231
conditions.push(
180-
`rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
232+
`"${tableName}".rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
181233
);
182234
}
183235

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

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

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

238-
function constructOrderByClause(resource, sort) {
239-
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`;
240305

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

244312
// We have to check if the attributes are valid for the resource
245313
// as we can't use parameterized queries for ORDER BY
@@ -255,21 +323,21 @@ function constructOrderByClause(resource, sort) {
255323
},
256324
'',
257325
);
258-
return `ORDER BY CASE ${attributes.join(', ')} ${whenClauses}END ${sortDirection}`;
326+
return `ORDER BY CASE ${attributesWithTableName.join(', ')} ${whenClauses}END ${sortDirection}`;
259327
} else if (attributes?.length > 0) {
260-
const commaSeparatedVals = attributes.join(', ');
328+
const commaSeparatedVals = attributesWithTableName.join(', ');
261329

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

268336
const attributesWithNoCollate = attributes
269-
.map((attr) => `${attr} COLLATE NOCASE ${sortDirection}`)
337+
.map((attr) => `"${tableName}".${attr} COLLATE NOCASE ${sortDirection}`)
270338
.join(', ');
271339
const attributesWithDirection = attributes
272-
.map((attr) => `${attr} ${sortDirection}`)
340+
.map((attr) => `"${tableName}".${attr} ${sortDirection}`)
273341
.join(', ');
274342
return `ORDER BY ${attributesWithNoCollate}, ${attributesWithDirection}`;
275343
} else if (modelMapping[resource]?.created_time) {

0 commit comments

Comments
 (0)