Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/drivers-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ on:
- 'packages/cubejs-postgres-driver/**'
- 'packages/cubejs-questdb-driver/**'
- 'packages/cubejs-redshift-driver/**'
- 'packages/cubejs-prestodb-driver/**'
- 'packages/cubejs-snowflake-driver/**'
- 'packages/cubejs-trino-driver/**'
- 'packages/cubejs-vertica-driver/**'

- 'packages/cubejs-backend-native/**'
Expand Down Expand Up @@ -57,7 +59,9 @@ on:
- 'packages/cubejs-postgres-driver/**'
- 'packages/cubejs-questdb-driver/**'
- 'packages/cubejs-redshift-driver/**'
- 'packages/cubejs-prestodb-driver/**'
- 'packages/cubejs-snowflake-driver/**'
- 'packages/cubejs-trino-driver/**'
- 'packages/cubejs-vertica-driver/**'

- 'packages/cubejs-backend-native/**'
Expand Down Expand Up @@ -289,6 +293,7 @@ jobs:
- snowflake-export-bucket-azure-via-storage-integration
- snowflake-export-bucket-gcs
- snowflake-export-bucket-gcs-prefix
- trino
use_tesseract_sql_planner: [ true ]
include:
- database: postgres
Expand All @@ -315,6 +320,8 @@ jobs:
use_tesseract_sql_planner: false
- database: questdb
use_tesseract_sql_planner: false
- database: trino
use_tesseract_sql_planner: false
fail-fast: false

steps:
Expand Down
1 change: 1 addition & 0 deletions packages/cubejs-duckdb-driver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"build": "rm -rf dist && npm run tsc",
"tsc": "tsc",
"watch": "tsc -w",
"unit": "jest --verbose dist/test/unit",
"integration": "npm run integration:duckdb",
"integration:duckdb": "jest --verbose dist/test",
"lint": "eslint src/* --ext .ts",
Expand Down
7 changes: 7 additions & 0 deletions packages/cubejs-duckdb-driver/src/DuckDBQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ export class DuckDBQuery extends BaseQuery {
delete templates.functions.WIDTH_BUCKET;
templates.expressions.like = '{{ expr }} {% if negated %}NOT {% endif %}LIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\'{% endif %}';
templates.expressions.ilike = '{{ expr }} {% if negated %}NOT {% endif %}ILIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\'{% endif %}';
// DuckDB has no default LIKE escape character - the `default_escape` gate on
// the two templates above exists for exactly that reason. The native planner
// escapes filter values with a backslash (BaseQuery's `like_escape_char`), so
// the filter path needs the clause unconditionally to interpret it; without
// one, `contains '%'` matches nothing instead of the rows containing a
// literal percent sign.
templates.tesseract.ilike = '{{ expr }} {% if negated %}NOT {% endif %}ILIKE {{ pattern }} ESCAPE \'\\\'';
// DuckDB `/` performs float division even for integer operands (since v0.8);
// `//` is integer division truncating toward zero (-7 // 2 = -3), matching
// PostgreSQL
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { prepareCompiler as originalPrepareCompiler } from '@cubejs-backend/schema-compiler';
import { DuckDBQuery } from '../../src/DuckDBQuery';

const prepareCompiler = (content: string) => originalPrepareCompiler({
localPath: () => __dirname,
dataSchemaFiles: () => Promise.resolve([
{ fileName: 'main.js', content }
])
}, { adapter: 'postgres' });

describe('DuckDBQuery SQL templates', () => {
// DuckDB has no default LIKE escape character - the `default_escape` gate on
// its `expressions.like`/`ilike` templates is the repo's own record of that.
// The native planner escapes `%`, `_` and `\` in the filter value (BaseQuery's
// `like_escape_char`), so the statement has to carry the clause that
// interprets that escaping; without one a user searching for a literal `%`
// matches nothing instead of the rows containing a percent sign.
it.each([['legacy', false], ['tesseract', true]])(
'escapes LIKE wildcards in filter values on the %s planner',
async (_name, useNativeSqlPlanner) => {
const { compiler, joinGraph, cubeEvaluator } = prepareCompiler(`
cube('orders', {
sql_table: 'orders',

measures: {
count: {
type: 'count',
},
},

dimensions: {
id: {
sql: 'id',
type: 'number',
primary_key: true,
},
status: {
sql: 'status',
type: 'string',
},
},
});
`);

await compiler.compile();

const query = new DuckDBQuery({ joinGraph, cubeEvaluator, compiler }, {
measures: ['orders.count'],
filters: [{ member: 'orders.status', operator: 'contains', values: ['%'] }],
useNativeSqlPlanner,
});

const [sql, params] = query.buildSqlAndParams();

expect(params).toEqual(['\\%']);

// Only the native planner emits the clause: the legacy path relies on
// DuckDB reading a bare backslash as the escape character, which is the
// behaviour it has always had here.
if (useNativeSqlPlanner) {
// eslint-disable-next-line quotes -- double quotes keep the SQL readable
expect(sql).toContain("ESCAPE '\\'");
} else {
expect(sql).not.toContain('ESCAPE');
}
}
);
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { prepareCompiler as originalPrepareCompiler } from '@cubejs-backend/schema-compiler';
import { PinotQuery } from '../src/PinotQuery';
import { PinotQuery } from '../../src/PinotQuery';

const prepareCompiler = (content: string) => originalPrepareCompiler({
localPath: () => __dirname,
Expand Down
15 changes: 15 additions & 0 deletions packages/cubejs-schema-compiler/src/adapter/BaseQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -4678,6 +4678,21 @@ export class BaseQuery {
lt: '{{ column }} < {{ param }}',
lte: '{{ column }} <= {{ param }}',
like_pattern: '{% if start_wild %}\'%\' || {% endif %}{{ value }}{% if end_wild %}|| \'%\'{% endif %}',
// Character the native planner uses to escape `%`, `_` and itself inside
// a user-supplied LIKE value, mirroring what BaseFilter.escapeWildcardChars
// does on the legacy path. Without it the planner skips escaping entirely
// and a user searching for a literal `%` gets a wildcard instead, matching
// every row. Backslash is the default LIKE escape character in Postgres,
// MySQL, BigQuery, ClickHouse and Cube Store, so no ESCAPE clause is
// needed here - and Cube Store's parser rejects one outright, which is
// why this must stay a bare escape character. Dialects whose LIKE has no
// default escape character add the explicit clause themselves: Presto and
// Trino in `like_pattern`, MSSQL, Oracle and Snowflake in
// `tesseract.ilike` (their pattern is wrapped, so the clause cannot go
// inside it), and DuckDB and Pinot likewise in `tesseract.ilike` - those
// two live in their driver packages rather than in this directory, so a
// sweep of only this directory will miss them.
like_escape_char: '\\',
always_true: '1 = 1'

},
Expand Down
6 changes: 5 additions & 1 deletion packages/cubejs-schema-compiler/src/adapter/MssqlQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,11 @@ export class MssqlQuery extends BaseQuery {
'WHERE DATEADD({{ minimal_time_unit }}, 1, date_from) <= max_date';

// MSSQL uses OFFSET/FETCH instead of LIMIT/OFFSET
templates.tesseract.ilike = 'LOWER({{ expr }}) {% if negated %}NOT {% endif %}LIKE LOWER({{ pattern }})';
// T-SQL has no default LIKE escape character, so the escaping the planner
// applies to the value (see BaseQuery's `like_escape_char`) only takes
// effect with an explicit clause. It goes on the predicate rather than in
// `like_pattern` because the pattern is wrapped in LOWER(...) here.
templates.tesseract.ilike = 'LOWER({{ expr }}) {% if negated %}NOT {% endif %}LIKE LOWER({{ pattern }}) ESCAPE \'\\\'';
templates.filters.like_pattern = 'CONCAT({% if start_wild %}\'%\'{% else %}\'\'{% endif %}, LOWER({{ value }}), {% if end_wild %}\'%\'{% else %}\'\'{% endif %})';
templates.statements.select = '{% if ctes %} WITH \n' +
'{{ ctes | join(\',\n\') }}\n' +
Expand Down
6 changes: 5 additions & 1 deletion packages/cubejs-schema-compiler/src/adapter/OracleQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,11 @@ export class OracleQuery extends BaseQuery {

templates.expressions.like = '{{ expr }} {% if negated %}NOT {% endif %}LIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\'{% endif %}';
delete templates.expressions.ilike;
templates.tesseract.ilike = 'LOWER({{ expr }}) {% if negated %}NOT {% endif %}LIKE LOWER({{ pattern }}){% if default_escape %} ESCAPE \'\\\'{% endif %}';
// Unconditional, unlike the `expressions.like` variant above: the native
// planner's filter path never sets `default_escape`, so gating on it left
// Oracle - which has no default LIKE escape character - applying the
// planner's escaping with nothing to interpret it.
templates.tesseract.ilike = 'LOWER({{ expr }}) {% if negated %}NOT {% endif %}LIKE LOWER({{ pattern }}) ESCAPE \'\\\'';

// Oracle has no `STRING` type (used by the default in CAST(... AS STRING),
// e.g. the multi-column count() concatenation). CAST to VARCHAR2 requires a
Expand Down
4 changes: 4 additions & 0 deletions packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ export class PrestodbQuery extends BaseQuery {
templates.tesseract.bool_param_cast = 'CAST({{ expr }} AS BOOLEAN)';
templates.tesseract.number_param_cast = 'CAST({{ expr }} AS DOUBLE)';
templates.filters.like_pattern = 'CONCAT({% if start_wild %}\'%\'{% else %}\'\'{% endif %}, LOWER({{ value }}), {% if end_wild %}\'%\'{% else %}\'\'{% endif %}) ESCAPE \'\\\'';
// Deliberately restated even though it currently matches the base value:
// the ESCAPE clause in the like_pattern above hardcodes this character, so
// the two have to move together. Inheriting it would let a change to the
// base silently desynchronise the escaping from the clause interpreting it.
templates.filters.like_escape_char = '\\';
templates.statements.time_series_select = 'SELECT from_iso8601_timestamp(dates.f) date_from, from_iso8601_timestamp(dates.t) date_to \n' +
'FROM (\n' +
Expand Down
5 changes: 5 additions & 0 deletions packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ export class SnowflakeQuery extends BaseQuery {
templates.expressions.like = '{{ expr }} {% if negated %}NOT {% endif %}LIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\\\'{% endif %}';
templates.expressions.ilike = '{{ expr }} {% if negated %}NOT {% endif %}ILIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\\\'{% endif %}';
templates.operators.is_not_distinct_from = 'IS NOT DISTINCT FROM';
// Snowflake has no default LIKE escape character, so the escaping the
// planner applies to the value needs an explicit clause - the same one
// SnowflakeFilter.likeIgnoreCase emits on the legacy path, and doubled for
// the same reason described there.
templates.tesseract.ilike = '{{ expr }} {% if negated %}NOT {% endif %}ILIKE {{ pattern }} ESCAPE \'\\\\\'';
templates.tesseract.join_types_full = 'FULL';
delete templates.types.interval;
return templates;
Expand Down
111 changes: 111 additions & 0 deletions packages/cubejs-schema-compiler/test/unit/like-filter-escaping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* eslint-disable no-restricted-syntax, quotes */
import { BigqueryQuery } from '../../src/adapter/BigqueryQuery';
import { ClickHouseQuery } from '../../src/adapter/ClickHouseQuery';
import { CubeStoreQuery } from '../../src/adapter/CubeStoreQuery';
import { MssqlQuery } from '../../src/adapter/MssqlQuery';
import { MysqlQuery } from '../../src/adapter/MysqlQuery';
import { OracleQuery } from '../../src/adapter/OracleQuery';
import { PostgresQuery } from '../../src/adapter/PostgresQuery';
import { PrestodbQuery } from '../../src/adapter/PrestodbQuery';
import { SnowflakeQuery } from '../../src/adapter/SnowflakeQuery';
import { TrinoQuery } from '../../src/adapter/TrinoQuery';
import { prepareJsCompiler } from './PrepareCompiler';

/**
* A LIKE-based filter must never let a `%`, `_` or `\` typed by a user act as a
* wildcard. That takes two cooperating pieces, and BOTH have to come from the
* dialect:
*
* 1. the value is escaped - on the native planner this only happens when the
* dialect defines the `filters/like_escape_char` template, otherwise the
* planner skips escaping entirely and the raw value becomes the pattern;
* 2. the emitted SQL interprets that escape character - either because the
* engine treats backslash as the default (Postgres, MySQL, BigQuery,
* ClickHouse, Cube Store) or because the statement carries an explicit
* ESCAPE clause (Presto/Trino, Snowflake, Oracle, MSSQL).
*
* Getting (1) without (2) is the dangerous combination: `contains '%'` silently
* matches every row instead of the rows containing a literal percent sign.
*/
describe('LIKE filter wildcard escaping', () => {
const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(`
cube('Names', {
sql: \`SELECT 1 AS id, 'a' AS name\`,
measures: {
count: { type: 'count' }
},
dimensions: {
id: { sql: 'id', type: 'number', primaryKey: true },
name: { sql: 'name', type: 'string' }
}
});
`);

const buildFilter = async (QueryClass: any, useNativeSqlPlanner: boolean, value = '%') => {
await compiler.compile();

const query = new QueryClass({ joinGraph, cubeEvaluator, compiler }, {
dimensions: ['Names.id'],
filters: [{ member: 'Names.name', operator: 'contains', values: [value] }],
useNativeSqlPlanner,
});

const [sql, params] = query.buildSqlAndParams();

return { sql: sql.replace(/\s+/g, ' '), params };
};

// Dialects whose LIKE treats backslash as the escape character with no clause.
// Cube Store belongs here and cannot be moved: its parser rejects `ESCAPE '\'`
// outright, so an explicit clause would break every pre-aggregation query.
const BACKSLASH_BY_DEFAULT: [string, any][] = [
['Postgres', PostgresQuery],
['MySQL', MysqlQuery],
['BigQuery', BigqueryQuery],
['ClickHouse', ClickHouseQuery],
['Cube Store', CubeStoreQuery],
];

// Dialects with no default escape character, which therefore have to say so.
// Snowflake doubles the backslash because it also unescapes the clause itself.
const NEEDS_EXPLICIT_CLAUSE: [string, any, string][] = [
['Presto', PrestodbQuery, "ESCAPE '\\'"],
['Trino', TrinoQuery, "ESCAPE '\\'"],
['Oracle', OracleQuery, "ESCAPE '\\'"],
['MSSQL', MssqlQuery, "ESCAPE '\\'"],
['Snowflake', SnowflakeQuery, "ESCAPE '\\\\'"],
];

describe.each([['legacy', false], ['tesseract', true]])('%s planner', (_name, native) => {
it.each(BACKSLASH_BY_DEFAULT)('%s escapes the value and needs no clause', async (dialect, QueryClass) => {
const { sql, params } = await buildFilter(QueryClass, native);

// MSSQL aside (covered below), every dialect escapes with a backslash.
expect(params).toEqual(['\\%']);
expect(sql).not.toMatch(/ESCAPE/i);
});

it.each(NEEDS_EXPLICIT_CLAUSE)('%s escapes the value and emits an explicit ESCAPE clause', async (dialect, QueryClass, clause) => {
const { sql, params } = await buildFilter(QueryClass, native);

if (dialect === 'MSSQL' && !native) {
// MSSQL's legacy path escapes by bracketing rather than by backslash, so
// it carries no clause. Both forms are correct; pin each rather than
// skipping, so this case still asserts something.
expect(params).toEqual(['[%]']);
} else {
expect(params).toEqual(['\\%']);
expect(sql).toContain(clause);
}
});

it('escapes underscores and backslashes too, not just percent', async () => {
expect((await buildFilter(PostgresQuery, native, '_')).params).toEqual(['\\_']);
expect((await buildFilter(PostgresQuery, native, '\\')).params).toEqual(['\\\\']);
});

it('leaves values with no special characters untouched', async () => {
expect((await buildFilter(PostgresQuery, native, 'plain')).params).toEqual(['plain']);
});
});
});
48 changes: 46 additions & 2 deletions packages/cubejs-testing-drivers/fixtures/pinot.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,29 @@
"windows / window functions, multi-stage & time-shift measures, month/quarter/",
"year interval arithmetic, and any timestamp/timezone rendering differences the",
"Pinot dialect does not yet cover).",
"---------------------------------------"
"---------------------------------------",
"---------------------------------------",
"PINOT LIKE FILTERS NEVER MATCH ",
"---------------------------------------",
"contains/startsWith/endsWith return empty on Pinot: it does not match a",
"non-constant CONCAT(...) LIKE pattern. Pre-existing and unrelated to",
"wildcard escaping - the recorded snapshots for the whole LIKE family are",
"Array [] for the same reason. Tracked in #11570.",
"Do not extend this list to the other escaping cases. What decides whether",
"one is meaningful here is which engine answers it, not how it asserts:",
" - the three ECommerce (pre-aggregated) cases are answered by Cube Store,",
" so Pinot LIKE never runs and this defect cannot reach them. They are",
" as protective on Pinot as anywhere else, and the underscore one is the",
" strongest case in the group: it asserts a specific non-empty row from",
" the rollup store.",
" - Products: notContains a literal percent sign is answered by Pinot and",
" passes on a real non-empty result, arrived at by the complement of the",
" defect - NOT LIKE over a never-matching pattern returns every row.",
" - the three remaining Products cases - contains, startsWith and endsWith",
" a literal percent sign - are answered by Pinot and are vacuous: each",
" expects an empty result, which is what Pinot returns for everything.",
" They still discriminate on every other engine, which is why they stay.",
"filtering Products: contains a literal underscore (no pre-aggregation)"
],
"tesseractSkip": [
"---------------------------------------",
Expand Down Expand Up @@ -293,6 +315,28 @@
"querying BigECommerce: null sum",
"querying BigECommerce: multi-stage group by time dimension",
"querying BigECommerce: two multi-stage branches sharing one pre-aggregation",
"Tesseract: SQL API: Timeshift measure from cube"
"Tesseract: SQL API: Timeshift measure from cube",
"---------------------------------------",
"PINOT LIKE FILTERS NEVER MATCH ",
"---------------------------------------",
"contains/startsWith/endsWith return empty on Pinot: it does not match a",
"non-constant CONCAT(...) LIKE pattern. Pre-existing and unrelated to",
"wildcard escaping - the recorded snapshots for the whole LIKE family are",
"Array [] for the same reason. Tracked in #11570.",
"Do not extend this list to the other escaping cases. What decides whether",
"one is meaningful here is which engine answers it, not how it asserts:",
" - the three ECommerce (pre-aggregated) cases are answered by Cube Store,",
" so Pinot LIKE never runs and this defect cannot reach them. They are",
" as protective on Pinot as anywhere else, and the underscore one is the",
" strongest case in the group: it asserts a specific non-empty row from",
" the rollup store.",
" - Products: notContains a literal percent sign is answered by Pinot and",
" passes on a real non-empty result, arrived at by the complement of the",
" defect - NOT LIKE over a never-matching pattern returns every row.",
" - the three remaining Products cases - contains, startsWith and endsWith",
" a literal percent sign - are answered by Pinot and are vacuous: each",
" expects an empty result, which is what Pinot returns for everything.",
" They still discriminate on every other engine, which is why they stay.",
"filtering Products: contains a literal underscore (no pre-aggregation)"
]
}
Loading
Loading