Repository: knex/knex
Pull request #4940 (2022-01-17 22:56:58)

Support of MATERIALIZED and NOT MATERIALIZED with WITH/CTE
- Add withMaterialized and withNotMaterialized functions for PG and SQLite to add 'materialized' and 'not materialized' keywords with WITH/CTE (see https://www.postgresql.org/docs/12/queries-with.html).
- Close https://github.com/knex/knex/issues/4470

kibertoad: Can you also add an integration test which would actually run the sql?

Patch:
diff --git a/lib/dialects/postgres/query/pg-querybuilder.js b/lib/dialects/postgres/query/pg-querybuilder.js
index 2c425928..2d4868dc 100644
--- a/lib/dialects/postgres/query/pg-querybuilder.js
+++ b/lib/dialects/postgres/query/pg-querybuilder.js
@@ -5,4 +5,34 @@ module.exports = class QueryBuilder_PostgreSQL extends QueryBuilder {
     this._single.using = tables;
     return this;
   }
+
+  withMaterialized(alias, statementOrColumnList, nothingOrStatement) {
+    this._validateWithArgs(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      'with'
+    );
+    return this.withWrapped(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      true
+    );
+  }
+
+  withNotMaterialized(alias, statementOrColumnList, nothingOrStatement) {
+    this._validateWithArgs(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      'with'
+    );
+    return this.withWrapped(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      false
+    );
+  }
 };
diff --git a/lib/dialects/sqlite3/index.js b/lib/dialects/sqlite3/index.js
index 0fed4b0f..b5c88e54 100644
--- a/lib/dialects/sqlite3/index.js
+++ b/lib/dialects/sqlite3/index.js
@@ -15,6 +15,7 @@ const TableCompiler = require('./schema/sqlite-tablecompiler');
 const ViewCompiler = require('./schema/sqlite-viewcompiler');
 const SQLite3_DDL = require('./schema/ddl');
 const Formatter = require('../../formatter');
+const QueryBuilder = require('./query/sqlite-querybuilder');
 
 class Client_SQLite3 extends Client {
   constructor(config) {
@@ -44,6 +45,10 @@ class Client_SQLite3 extends Client {
     return new SqliteQueryCompiler(this, builder, formatter);
   }
 
+  queryBuilder() {
+    return new QueryBuilder(this);
+  }
+
   viewCompiler(builder, formatter) {
     return new ViewCompiler(this, builder, formatter);
   }
diff --git a/lib/dialects/sqlite3/query/sqlite-querybuilder.js b/lib/dialects/sqlite3/query/sqlite-querybuilder.js
new file mode 100644
index 00000000..08fba898
--- /dev/null
+++ b/lib/dialects/sqlite3/query/sqlite-querybuilder.js
@@ -0,0 +1,33 @@
+const QueryBuilder = require('../../../query/querybuilder.js');
+
+module.exports = class QueryBuilder_SQLite3 extends QueryBuilder {
+  withMaterialized(alias, statementOrColumnList, nothingOrStatement) {
+    this._validateWithArgs(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      'with'
+    );
+    return this.withWrapped(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      true
+    );
+  }
+
+  withNotMaterialized(alias, statementOrColumnList, nothingOrStatement) {
+    this._validateWithArgs(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      'with'
+    );
+    return this.withWrapped(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      false
+    );
+  }
+};
diff --git a/lib/query/querybuilder.js b/lib/query/querybuilder.js
index ef9f118b..cf7b5dfd 100644
--- a/lib/query/querybuilder.js
+++ b/lib/query/querybuilder.js
@@ -118,25 +118,82 @@ class Builder extends EventEmitter {
 
   // With
   // ------
+  isValidStatementArg(statement) {
+    return (
+      typeof statement === 'function' ||
+      statement instanceof Builder ||
+      (statement && statement.isRawInstance)
+    );
+  }
+
+  _validateWithArgs(alias, statementOrColumnList, nothingOrStatement, method) {
+    const [query, columnList] =
+      typeof nothingOrStatement === 'undefined'
+        ? [statementOrColumnList, undefined]
+        : [nothingOrStatement, statementOrColumnList];
+    if (typeof alias !== 'string') {
+      throw new Error(`${method}() first argument must be a string`);
+    }
+
+    if (this.isValidStatementArg(query) && typeof columnList === 'undefined') {
+      // Validated as two-arg variant (alias, statement).
+      return;
+    }
+
+    // Attempt to interpret as three-arg variant (alias, columnList, statement).
+    const isNonEmptyNameList =
+      Array.isArray(columnList) &&
+      columnList.length > 0 &&
+      columnList.every((it) => typeof it === 'string');
+    if (!isNonEmptyNameList) {
+      throw new Error(
+        `${method}() second argument must be a statement or non-empty column name list.`
+      );
+    }
+
+    if (this.isValidStatementArg(query)) {
+      return;
+    }
+    throw new Error(
+      `${method}() third argument must be a function / QueryBuilder or a raw when its second argument is a column name list`
+    );
+  }
 
   with(alias, statementOrColumnList, nothingOrStatement) {
-    validateWithArgs(alias, statementOrColumnList, nothingOrStatement, 'with');
+    this._validateWithArgs(
+      alias,
+      statementOrColumnList,
+      nothingOrStatement,
+      'with'
+    );
     return this.withWrapped(alias, statementOrColumnList, nothingOrStatement);
   }
 
+  withMaterialized(alias, statementOrColumnList, nothingOrStatement) {
+    throw new Error('With materialized is not supported by this dialect');
+  }
+
+  withNotMaterialized(alias, statementOrColumnList, nothingOrStatement) {
+    throw new Error('With materialized is not supported by this dialect');
+  }
+
   // Helper for compiling any advanced `with` queries.
-  withWrapped(alias, statementOrColumnList, nothingOrStatement) {
+  withWrapped(alias, statementOrColumnList, nothingOrStatement, materialized) {
     const [query, columnList] =
       typeof nothingOrStatement === 'undefined'
         ? [statementOrColumnList, undefined]
         : [nothingOrStatement, statementOrColumnList];
-    this._statements.push({
+    const statement = {
       grouping: 'with',
       type: 'withWrapped',
       alias: alias,
       columnList,
       value: query,
-    });
+    };
+    if (materialized !== undefined) {
+      statement.materialized = materialized;
+    }
+    this._statements.push(statement);
     return this;
   }
 
@@ -144,7 +201,7 @@ class Builder extends EventEmitter {
   // ------
 
   withRecursive(alias, statementOrColumnList, nothingOrStatement) {
-    validateWithArgs(
+    this._validateWithArgs(
       alias,
       statementOrColumnList,
       nothingOrStatement,
@@ -1656,49 +1713,6 @@ class Builder extends EventEmitter {
   }
 }
 
-const isValidStatementArg = (statement) =>
-  typeof statement === 'function' ||
-  statement instanceof Builder ||
-  (statement && statement.isRawInstance);
-
-const validateWithArgs = function (
-  alias,
-  statementOrColumnList,
-  nothingOrStatement,
-  method
-) {
-  const [query, columnList] =
-    typeof nothingOrStatement === 'undefined'
-      ? [statementOrColumnList, undefined]
-      : [nothingOrStatement, statementOrColumnList];
-  if (typeof alias !== 'string') {
-    throw new Error(`${method}() first argument must be a string`);
-  }
-
-  if (isValidStatementArg(query) && typeof columnList === 'undefined') {
-    // Validated as two-arg variant (alias, statement).
-    return;
-  }
-
-  // Attempt to interpret as three-arg variant (alias, columnList, statement).
-  const isNonEmptyNameList =
-    Array.isArray(columnList) &&
-    columnList.length > 0 &&
-    columnList.every((it) => typeof it === 'string');
-  if (!isNonEmptyNameList) {
-    throw new Error(
-      `${method}() second argument must be a statement or non-empty column name list.`
-    );
-  }
-
-  if (isValidStatementArg(query)) {
-    return;
-  }
-  throw new Error(
-    `${method}() third argument must be a function / QueryBuilder or a raw when its second argument is a column name list`
-  );
-};
-
 Builder.prototype.select = Builder.prototype.columns;
 Builder.prototype.column = Builder.prototype.columns;
 Builder.prototype.andWhereNot = Builder.prototype.whereNot;
diff --git a/lib/query/querycompiler.js b/lib/query/querycompiler.js
index 59c7e784..ba209749 100644
--- a/lib/query/querycompiler.js
+++ b/lib/query/querycompiler.js
@@ -1170,6 +1170,12 @@ class QueryCompiler {
         ) +
         ')'
       : '';
+    const materialized =
+      statement.materialized === undefined
+        ? ''
+        : statement.materialized
+        ? 'materialized '
+        : 'not materialized ';
     return (
       (val &&
         columnize_(
@@ -1179,7 +1185,9 @@ class QueryCompiler {
           this.bindingsHolder
         ) +
           columnList +
-          ' as (' +
+          ' as ' +
+          materialized +
+          '(' +
           val +
           ')') ||
       ''
diff --git a/types/index.d.ts b/types/index.d.ts
index d9bf6e51..aa846c95 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -520,6 +520,8 @@ export declare namespace Knex {
 
     // Withs
     with: With<TRecord, TResult>;
+    withMaterialized: With<TRecord, TResult>;
+    withNotMaterialized: With<TRecord, TResult>;
     withRecursive: With<TRecord, TResult>;
     withRaw: WithRaw<TRecord, TResult>;
     withSchema: WithSchema<TRecord, TResult>;
@@ -546,7 +548,7 @@ export declare namespace Knex {
     whereNotIn: WhereIn<TRecord, TResult>;
     orWhereNotIn: WhereIn<TRecord, TResult>;
     whereLike: Where<TRecord, TResult>;
-    whereIlike: Where<TRecord, TResult>;
+    whereILike: Where<TRecord, TResult>;
     whereNull: WhereNull<TRecord, TResult>;
     orWhereNull: WhereNull<TRecord, TResult>;
     whereNotNull: WhereNull<TRecord, TResult>;


Test patch:
diff --git a/test/integration2/query/select/selects.spec.js b/test/integration2/query/select/selects.spec.js
index 92b98bd8..57661ed5 100644
--- a/test/integration2/query/select/selects.spec.js
+++ b/test/integration2/query/select/selects.spec.js
@@ -1255,6 +1255,34 @@ describe('Selects', function () {
           );
       });
 
+      describe('with (not) materialized tests', () => {
+        before(async function () {
+          if (!isPostgreSQL(knex) && !isSQLite(knex)) {
+            return this.skip();
+          }
+          await knex('test_default_table').truncate();
+          await knex('test_default_table').insert([
+            { string: 'something', tinyint: 1 },
+          ]);
+        });
+
+        it('with materialized', async function () {
+          const materialized = await knex('t')
+            .withMaterialized('t', knex('test_default_table'))
+            .from('t')
+            .first();
+          expect(materialized.tinyint).to.equal(1);
+        });
+
+        it('with not materialized', async function () {
+          const notMaterialized = await knex('t')
+            .withNotMaterialized('t', knex('test_default_table'))
+            .from('t')
+            .first();
+          expect(notMaterialized.tinyint).to.equal(1);
+        });
+      });
+
       describe('json selections', () => {
         before(async () => {
           await knex.schema.dropTableIfExists('cities');
diff --git a/test/unit/knex.js b/test/unit/knex.js
index a777fcdd..021e04ac 100644
--- a/test/unit/knex.js
+++ b/test/unit/knex.js
@@ -628,7 +628,9 @@ describe('knex', () => {
       await knex('some_nonexisten_table')
         .select()
         .catch((err) => {
-          expect(err.stack.split('\n')[1]).to.match(/at createQueryBuilder \(/); // the index 1 might need adjustment if the code is refactored
+          expect(err.stack.split('\n')[1]).to.match(
+            /at Object.queryBuilder \(/
+          ); // the index 1 might need adjustment if the code is refactored
           expect(typeof err.originalStack).to.equal('string');
         });
 
diff --git a/test/unit/query/builder.js b/test/unit/query/builder.js
index 511b7841..e3054e45 100644
--- a/test/unit/query/builder.js
+++ b/test/unit/query/builder.js
@@ -9482,6 +9482,40 @@ describe('QueryBuilder', () => {
     );
   });
 
+  it("wrapped 'withMaterialized' clause update", () => {
+    testsql(
+      qb()
+        .withMaterialized('withClause', function () {
+          this.select('foo').from('users');
+        })
+        .update({ foo: 'updatedFoo' })
+        .where('email', '=', 'foo')
+        .from('users'),
+      {
+        sqlite3:
+          'with `withClause` as materialized (select `foo` from `users`) update `users` set `foo` = ? where `email` = ?',
+        pg: 'with "withClause" as materialized (select "foo" from "users") update "users" set "foo" = ? where "email" = ?',
+      }
+    );
+  });
+
+  it("wrapped 'withNotMaterialized' clause update", () => {
+    testsql(
+      qb()
+        .withNotMaterialized('withClause', function () {
+          this.select('foo').from('users');
+        })
+        .update({ foo: 'updatedFoo' })
+        .where('email', '=', 'foo')
+        .from('users'),
+      {
+        sqlite3:
+          'with `withClause` as not materialized (select `foo` from `users`) update `users` set `foo` = ? where `email` = ?',
+        pg: 'with "withClause" as not materialized (select "foo" from "users") update "users" set "foo" = ? where "email" = ?',
+      }
+    );
+  });
+
   it("wrapped 'with' clause delete", () => {
     testsql(
       qb()