diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/SubqueryScope.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/SubqueryScope.scala index 9b078d04..7b501865 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/SubqueryScope.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/SubqueryScope.scala @@ -18,6 +18,8 @@ package app.softnetwork.elastic.sql.query import app.softnetwork.elastic.sql.Identifier +import scala.util.DynamicVariable + /** Does a nested statement read a correlation name of an ENCLOSING statement? * * ONE detector, structural, on the AST the parser already built (story 22.1, consumed by stories @@ -267,6 +269,46 @@ object SubqueryScope { */ def correlationNames(s: SingleSearch): Set[String] = scopeOf(s).names + /** Does `id` read an ENCLOSING scope? A dotted name the statement left unresolved whose HEAD is + * one of `outerOnly` -- the enclosing correlation names minus the statement's own, so a name the + * statement declares itself shadows an outer one (innermost wins). + * + * ONE predicate, three readers: the correlation walk (story 22.2 -- routing), the LATERAL walk + * (story 22.3) and `SingleSearch.derivedScopeCheck`, which must not call such a name a column + * its derived table does not project. A second spelling of the test would let the check refuse + * what the router treats as correlated, or accept what it treats as a column. + */ + private[query] def readsEnclosingScope(id: Identifier, outerOnly: Set[String]): Boolean = + id.tableAlias.isEmpty && !id.nested && id.name.contains(".") && + outerOnly.contains(id.name.split("\\.", 2)(0)) + + /** The statements ENCLOSING the one being validated, innermost first -- what + * `SingleSearch.validate()` runs the nested statements' validation inside of. + * + * 🔴 Why a thread-scoped carrier, not a parameter. A WHERE subquery's body is validated through + * `Where.validate()` -> `Criteria.validate()` -> `SubqueryCriteria.commonChecks`, and a derived + * table's through `From.validate()` -> `DerivedTable.validate()`: `Token.validate()` takes no + * argument and is implemented across the whole `Criteria` hierarchy, and the node is `update`d + * with its enclosing statement only (`correlatedRefs` is relative to ONE level). So nothing on + * that path can tell the body that `c` in `c.id` is an enclosing alias, and the derived-scope + * check refused a valid correlated reference as a column its derived table does not project. + * `SingleSearch.validate()` sets the value around its own rules and restores it on exit; + * validation is synchronous, so the value is read on the thread that set it. Outside a + * `validate()` it is empty: a statement validated on its own has no enclosing scope. + */ + private val enclosingStatements: DynamicVariable[List[SingleSearch]] = + new DynamicVariable[List[SingleSearch]](Nil) + + /** The statements enclosing the one being validated, innermost first ([[enclosingStatements]]). + */ + private[query] def enclosing: List[SingleSearch] = enclosingStatements.value + + /** Run `body` -- the validation of `statement`'s own rules and of every statement nested in it -- + * with `statement` added to the enclosing statements. + */ + private[query] def validatingInside[T](statement: SingleSearch)(body: => T): T = + enclosingStatements.withValue(statement :: enclosingStatements.value)(body) + def correlatedReferences(body: DqlStatement, outer: SingleSearch): Seq[Identifier] = correlatedReferences(body, correlationNames(outer)) @@ -309,11 +351,7 @@ object SubqueryScope { val outerOnly = outerScopes -- innerNames // innermost wins val direct = if (outerOnly.isEmpty) Nil - else - inner.referencedIdentifiers.filter { id => - id.tableAlias.isEmpty && !id.nested && id.name.contains(".") && - outerOnly.contains(id.name.split("\\.", 2)(0)) - } + else inner.referencedIdentifiers.filter(readsEnclosingScope(_, outerOnly)) // A derived table NESTED in this body, and (story 22.2) a WHERE SUBQUERY nested in it, // are walked with this statement's names added — so a reference two levels in to the // OUTERMOST alias is caught at the outermost `update()` too. For the LATERAL question the diff --git a/sql/src/main/scala/app/softnetwork/elastic/sql/query/package.scala b/sql/src/main/scala/app/softnetwork/elastic/sql/query/package.scala index 34d30069..106da279 100644 --- a/sql/src/main/scala/app/softnetwork/elastic/sql/query/package.scala +++ b/sql/src/main/scala/app/softnetwork/elastic/sql/query/package.scala @@ -1270,6 +1270,49 @@ package object query { .filter(_.name.nonEmpty) .distinct + /** Why a HAVING leaf that QUALIFIES a SELECT-list alias with a table alias (`HAVING + * d.avg_salary > 1` over `AVG(e.salary) AS avg_salary`) cannot be filtered on, or `None` when + * `leaf` is not one. + * + * Such a leaf IS neither a GROUP BY key nor an aggregate (rule (a) in `validate()` is right to + * refuse it), but the rule's own remedy, "move it to WHERE", sends the user the wrong way. The + * reason names the alias and what it aliases, the way the relational engine names the same + * mistake in ORDER BY and GROUP BY. + * + * Only where `q.n` cannot be `q`'s own column, the three cases that engine's check exempts: + * the alias names a COMPUTED value or a RENAMED column of `q` itself -- not `q.n` (the alias + * of that very column) and not another table's plain column (which `q` may own too) -- and the + * statement writes `q.n` as a column nowhere else (a SELECT item's source, WHERE, GROUP BY, a + * JOIN's ON). Everywhere else the leaf may be a real column, and the generic reason stands. + */ + private[query] def qualifiedSelectAlias(leaf: Identifier): Option[String] = + leaf.tableAlias.filter(_ => leaf.name.nonEmpty && !leaf.name.contains(".")).flatMap { q => + val writtenAsColumn = referencedIdentifiersBySite.exists { + case (ReferenceSite.InHaving | ReferenceSite.InOrderBy, _) => false + case (_, id) => + id.tableAlias.exists(_.equalsIgnoreCase(q)) && id.name.equalsIgnoreCase(leaf.name) + } + select.fields + .find(_.fieldAlias.exists(_.alias.equalsIgnoreCase(leaf.name))) + .filterNot(_ => writtenAsColumn) + .flatMap { f => + val source = f.identifier + val computed = source.functions.nonEmpty || source.name.isEmpty + val renamed = !computed && source.tableAlias.exists(_.equalsIgnoreCase(q)) && + !source.name.equalsIgnoreCase(leaf.name) + if (!computed && !renamed) None + else + Some( + s"In HAVING, '$q.${leaf.name}' qualifies the SELECT-list alias '${leaf.name}' " + + s"with table alias '$q', but that alias names " + + (if (computed) "a computed value" else s"a renamed column (${source.sql})") + + s", not a column of '$q'. Write it bare, or reference what it aliases " + + s"(${source.sql}); if '$q' really has a column named '${leaf.name}', rename the " + + "alias." + ) + } + } + private[query] def havingScopeOf(e: Expression): HavingScope = if (e.referencedIdentifiers.exists(_.bucketMetrics.nonEmpty)) HavingScope.Metric else { @@ -1446,7 +1489,10 @@ package object query { * where a clause may name one (WHERE, GROUP BY, HAVING, ORDER BY, ON, another SELECT item) * — in the item that defines it, the name is checked as a column; * - a dotted name whose head is no source (`item.product`) is checked against a LONE derived - * source's projection, and refused with the names it does project; + * source's projection, and refused with the names it does project -- unless its head is a + * correlation name of an ENCLOSING statement (`c.id` in `WHERE EXISTS (SELECT 1 FROM (…) d + * WHERE d.a = c.id)`): that is a correlated reference, or a LATERAL one `lateralCheck` + * refuses by name, never a column of this statement's derived table; * - an ordinal or literal (empty `name`), `*` and `COUNT(*)` are never derived-table * references; * - a derived table whose projection is OPAQUE (`outputNames == None`, i.e. a bare `SELECT @@ -1456,7 +1502,7 @@ package object query { * A dotted remainder (`d.items.name`) is struct/nested access INTO a projected column, so the * HEAD segment is what is compared, never the whole path. */ - private lazy val derivedScopeCheck: Either[String, Unit] = { + private def derivedScopeCheck(enclosing: Seq[SingleSearch]): Either[String, Unit] = { val scopes = from.derivedTables if (scopes.isEmpty) Right(()) else { @@ -1487,6 +1533,18 @@ package object query { // a path into one of that source's columns, so it is checked against its projection — by // the head, as a qualified path is, or whole (a projected name may itself hold a dot). With // several sources it stays unchecked: which one it reads is not known without a mapping. + // + // 🔴 ...unless the head names a source of an ENCLOSING statement. In `WHERE EXISTS (SELECT 1 + // FROM (SELECT a FROM x) d WHERE d.a = c.id)` the body cannot resolve `c`, and without its + // enclosing statements it refused the valid correlated `c.id` as a column `d` does not + // project, preempting the relational engine's own reason. The test is the one the router + // makes (`SubqueryScope.readsEnclosingScope`), with the names every enclosing statement + // declares minus this one's own, which shadow them: what it calls correlated routes as + // correlated, and a derived body naming an outer alias is still refused, by + // `lateralCheck`, as LATERAL. Computed only when an unresolved dotted name needs it. + lazy val enclosingOnly: Set[String] = + enclosing.flatMap(SubqueryScope.correlationNames).toSet -- + SubqueryScope.correlationNames(this) val lone: Option[DerivedTable] = here.head.sources.filterNot(_.isInstanceOf[SubqueryScope.UnnestSource]) match { case Seq(src: SubqueryScope.DerivedSource) => scopes.get(src.alias) @@ -1519,7 +1577,8 @@ package object query { case SubqueryScope.Resolved(0, src: SubqueryScope.DerivedSource, column) => scopes.get(src.alias).map(_ -> Seq(column.split("\\.", 2)(0))) case SubqueryScope.Unresolved - if id.tableAlias.isEmpty && id.table.isEmpty && id.name.contains(".") => + if id.tableAlias.isEmpty && id.table.isEmpty && id.name.contains(".") && + !SubqueryScope.readsEnclosingScope(id, enclosingOnly) => lone.map(_ -> Seq(id.name.split("\\.", 2)(0), id.name)) case _ => None // Ambiguous / an enclosing scope: never guessed at } @@ -1641,7 +1700,17 @@ package object query { orderBy.toSeq.flatMap(_.sorts.map(_.field)) ++ groupBy.toSeq.flatMap(_.buckets.map(_.identifier)) + /** Validates this statement INSIDE the statements that enclose it (`SubqueryScope.enclosing`, + * empty for a statement validated on its own), and every statement nested in it -- a WHERE + * subquery's body, a derived table's, a CTE's -- inside this one: the derived-scope check + * reads them to tell a correlated reference from a column its derived table does not project. + */ override def validate(): Either[String, Unit] = { + val enclosing = SubqueryScope.enclosing + SubqueryScope.validatingInside(this)(validateWithin(enclosing)) + } + + private def validateWithin(enclosing: Seq[SingleSearch]): Either[String, Unit] = { for { // Story 22.5 — an UNREFERENCED CTE's body reaches `DerivedTable.validate()` through no // path at all (nothing in the FROM tree points at it), so its own GROUP BY / HAVING rules @@ -1652,7 +1721,7 @@ package object query { _ <- from.validate() // AFTER `from.validate()` so a derived table's OWN body is validated first, and BEFORE // every clause rule so the scope message wins over a downstream symptom. - _ <- derivedScopeCheck + _ <- derivedScopeCheck(enclosing) _ <- lateralCheck _ <- select.validate() _ <- where.map(_.validate()).getOrElse(Right(())) @@ -1744,11 +1813,24 @@ package object query { // `filter(_.name.nonEmpty)` probe cannot see a FUNCTION of one, which is S8's original // hole. MEASURED: `SELECT COUNT(*) AS c FROM t HAVING UPPER(status) = 'A'` answered // `{"c":{"value":4}}` with the predicate gone. + // + // ⚠️ It applies to a statement the relational engine evaluates too, and that is MEASURED, + // not assumed: there DuckDB refuses the same column (`column ... must appear in the + // GROUP BY clause`), but only after every leg has run, behind "Attempting to execute an + // unsuccessful or closed pending query result". What was wrong for such a statement is + // the REMEDY when the leaf qualifies a SELECT-list alias (`HAVING d.avg_salary > 1`): + // "move it to WHERE" preempted the engine's precise reason, so the rule gives that + // reason itself (`qualifiedSelectAlias`). havingLeaves.filter(e => havingScopeOf(e) == HavingScope.Unscoped).headOption match { case Some(e) => Left( - s"HAVING can only filter on a GROUP BY key or on an aggregate; ${e.sql} is " + - "neither. Move it to WHERE, or add its column to the GROUP BY." + namedLeavesOf(e) + .map(qualifiedSelectAlias) + .collectFirst { case Some(reason) => reason } + .getOrElse( + s"HAVING can only filter on a GROUP BY key or on an aggregate; ${e.sql} is " + + "neither. Move it to WHERE, or add its column to the GROUP BY." + ) ) case None => Right(()) } diff --git a/sql/src/test/scala/app/softnetwork/elastic/sql/parser/DerivedTableSpec.scala b/sql/src/test/scala/app/softnetwork/elastic/sql/parser/DerivedTableSpec.scala index d256d63e..630117b4 100644 --- a/sql/src/test/scala/app/softnetwork/elastic/sql/parser/DerivedTableSpec.scala +++ b/sql/src/test/scala/app/softnetwork/elastic/sql/parser/DerivedTableSpec.scala @@ -507,6 +507,151 @@ class DerivedTableSpec extends AnyFlatSpec with Matchers { ).foreach(parse) } + // ── a correlated reference over a derived table is not an unprojected column ─────────────── + + /** A WHERE subquery whose FROM is a lone derived table, reading `ref` -- in each position a + * subquery can take, both sides of a comparison and the body's own SELECT list. `outer` is the + * enclosing statement's own column, the left operand of the forms that take one. + */ + private def subqueryPositions(dt: String, ref: String, outer: String): Seq[String] = Seq( + s"EXISTS (SELECT 1 FROM $dt WHERE d.a = $ref)", + s"EXISTS (SELECT 1 FROM $dt WHERE $ref = d.a)", + s"NOT EXISTS (SELECT 1 FROM $dt WHERE d.a = $ref)", + s"$outer IN (SELECT d.a FROM $dt WHERE d.a = $ref)", + s"$outer > (SELECT MAX(d.a) FROM $dt WHERE d.a = $ref)", + s"$outer > ALL (SELECT d.a FROM $dt WHERE d.a = $ref)", + s"$outer IN (SELECT $ref FROM $dt WHERE d.a = 1)" + ) + + private val derivedBodies = Seq( + "(SELECT a FROM x) d", + "(SELECT a FROM x WHERE a > 0) d", + "(SELECT a, COUNT(*) AS n FROM x GROUP BY a) d", + "(SELECT a FROM x UNION ALL SELECT a FROM y) d" + ) + + /** The enclosing FROM and the correlated reference it makes valid: an aliased index, an unaliased + * one (its name IS the correlation name) and a derived table. + */ + private val enclosingSources = Seq( + "customers c" -> "c.id", + "customers" -> "customers.id", + "(SELECT id FROM customers) c" -> "c.id" + ) + + /** Every WHERE-subquery node's `correlatedRefs`, at any depth -- what the router reads. */ + private def correlatedNames(s: SingleSearch): Seq[String] = + s.whereSubqueries.flatMap(n => + n.correlatedRefs.map(_.name) ++ n.inner.toSeq.flatMap(correlatedNames) + ) ++ + s.from.derivedTables.values.toSeq.flatMap(_.query match { + case b: SingleSearch => correlatedNames(b) + case _ => Nil + }) + + /** 🔴 REGRESSION PIN -- the unprojected-column check reads a dotted name whose head is no source + * of the statement's FROM as a path into its LONE derived source, and a WHERE subquery's body is + * validated without the statement that encloses it. So the correlated `c.id` of `WHERE EXISTS + * (SELECT 1 FROM (SELECT a FROM x) d WHERE d.a = c.id)` was refused as "Column 'c.id' is not + * projected by derived table 'd'", preempting the relational engine's own reason for the shape. + * MEASURED on its shapes (EXISTS, NOT EXISTS, IN, a scalar comparison, ALL, an outer column in + * the body's SELECT list, two levels in, inside a derived body): each parsed before that check, + * and after it each was refused at parse, replacing every venue's own reason -- the core + * gateway's "requires the relational engine", the relational engine's "cannot carry a derived + * table". + * + * The check exempts exactly what the ROUTER reads as correlated (`correlatedRefs`), so an + * accepted statement is also routed as correlated -- asserted below, not assumed. + */ + "A correlated reference over a derived table" should "be accepted and routed as correlated" in { + val population = for { + (source, ref) <- enclosingSources + dt <- derivedBodies + predicate <- subqueryPositions(dt, ref, outer = ref) + } yield s"SELECT $ref FROM $source WHERE $predicate" -> ref + population should have size 84 + population.foreach { case (sql, ref) => + val s = parse(sql) + withClue(s"[$sql] ") { + s.relationalClosureRequired shouldBe true + correlatedNames(s) should contain(ref) + } + } + } + + it should "be accepted two levels in, and inside a derived table's body" in { + Seq( + // the innermost body reads the OUTERMOST statement, and the middle one + "SELECT c.id FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cid = c.id AND " + + "EXISTS (SELECT 1 FROM (SELECT region FROM orders) d WHERE d.region = c.region))" -> "c.region", + "SELECT c.id FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cid = c.id AND " + + "EXISTS (SELECT 1 FROM (SELECT region FROM orders) d WHERE d.region = o.region))" -> "o.region", + // a correlated subquery entirely inside a derived table's body + "SELECT t.id FROM (SELECT c.id FROM customers c WHERE EXISTS " + + "(SELECT 1 FROM (SELECT a FROM x) d WHERE d.a = c.id)) t" -> "c.id", + // both the enclosing source and the body's are derived tables + "SELECT d1.cid FROM (SELECT cid FROM orders) d1 WHERE EXISTS " + + "(SELECT 1 FROM (SELECT id FROM customers) d2 WHERE d2.id = d1.cid)" -> "d1.cid" + ).foreach { case (sql, ref) => + withClue(s"[$sql] ") { correlatedNames(parse(sql)) should contain(ref) } + } + } + + it should "leave a genuinely unknown name refused, in every position, with the same message" in { + for { + dt <- derivedBodies + predicate <- subqueryPositions(dt, "zz.id", outer = "c.id") + } rejects( + s"SELECT c.id FROM customers c WHERE $predicate", + "Column 'zz.id' is not projected by derived table 'd' (it projects: a" + ) + // beside a valid correlated reference, the unknown one is the one named + rejects( + "SELECT c.id FROM customers c WHERE EXISTS " + + "(SELECT 1 FROM (SELECT a FROM x) d WHERE d.a = c.id AND zz.k = 1)", + "Column 'zz.k' is not projected by derived table 'd' (it projects: a)" + ) + // two levels in + rejects( + "SELECT c.id FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.cid = c.id AND " + + "EXISTS (SELECT 1 FROM (SELECT region FROM orders) d WHERE d.region = zz.region))", + "Column 'zz.region' is not projected by derived table 'd' (it projects: region)" + ) + } + + it should "resolve to the body's OWN source when the body declares the same name" in { + // `c` is the body's derived table here, shadowing the enclosing `c` + rejects( + "SELECT c.id FROM customers c WHERE EXISTS (SELECT 1 FROM (SELECT a FROM x) c WHERE c.id = 1)", + "Column 'id' is not projected by derived table 'c' (it projects: a)" + ) + } + + it should "leave a derived body naming an outer alias to the LATERAL refusal" in { + rejects( + "SELECT c.id, t.a FROM customers c JOIN " + + "(SELECT d.a FROM (SELECT a FROM x) d WHERE d.a = c.id) t ON t.a = c.id", + "LATERAL is not supported", + "'c.id' inside derived table 't'" + ) + } + + it should "still refuse the body validated on its own, where no statement encloses it" in { + val body = "SELECT 1 FROM (SELECT a FROM x) d WHERE d.a = c.id" + val reason = "Column 'c.id' is not projected by derived table 'd' (it projects: a)" + rejects(body, reason) + parse(s"SELECT c.id FROM customers c WHERE EXISTS ($body)") + // the enclosing statements are those of ONE validation, never left behind for the next + rejects(body, reason) + } + + it should "still refuse an UN-qualified outer name: an outer reference must be qualified" in { + rejects( + "SELECT c.id FROM customers c WHERE EXISTS (SELECT 1 FROM (SELECT a FROM x) d WHERE d.a = id)", + "Column 'id' is not projected by derived table 'd' (it projects: a)" + ) + } + // ── rejections that MUST be ours (AD-3 / AD-6) ───────────────────────────────────────────── "A derived table" should "require an alias (PD-1)" in { diff --git a/sql/src/test/scala/app/softnetwork/elastic/sql/query/HavingOverAggregateFunctionSpec.scala b/sql/src/test/scala/app/softnetwork/elastic/sql/query/HavingOverAggregateFunctionSpec.scala index 77b06c31..b66720a6 100644 --- a/sql/src/test/scala/app/softnetwork/elastic/sql/query/HavingOverAggregateFunctionSpec.scala +++ b/sql/src/test/scala/app/softnetwork/elastic/sql/query/HavingOverAggregateFunctionSpec.scala @@ -474,6 +474,66 @@ class HavingOverAggregateFunctionSpec extends AnyFlatSpec with Matchers with Opt ) } + private val joinedAgg = + "SELECT d.dept_name, COUNT(*) AS headcount, AVG(e.salary) AS avg_salary " + + "FROM emp e JOIN dept d ON e.dept_id = d.dept_id GROUP BY d.dept_name HAVING " + + /** 🔴 REGRESSION PIN -- a leaf qualifying a SELECT-list alias is refused by this rule, rightly, + * but its remedy ("move it to WHERE") preempted the relational engine's precise reason for the + * same statement. The rule now gives that reason itself; the verdict does not move. + */ + it should "name a qualified SELECT-list alias, and what it aliases" in { + Seq( + joinedAgg + "d.avg_salary > 1" -> "'d.avg_salary' qualifies the SELECT-list alias 'avg_salary'", + joinedAgg + "e.avg_salary > 1" -> "'e.avg_salary' qualifies the SELECT-list alias 'avg_salary'" + ).foreach { case (sql, reason) => + val msg = rejection(sql) + withClue(s"[$sql] msg=[$msg] ") { + msg should include(reason) + msg should include("a computed value") + msg should include("(AVG(e.salary))") + msg should not include "Move it to WHERE" + } + } + rejection( + "SELECT UPPER(d.dept_name) AS label, COUNT(*) AS cnt FROM emp e JOIN dept d " + + "ON e.dept_id = d.dept_id GROUP BY d.dept_name HAVING d.label = 'ENG'" + ) should include("'d.label' qualifies the SELECT-list alias 'label'") + // a RENAMED column of the same table alias + rejection( + "SELECT e.name AS label, d.dept_name, COUNT(*) AS cnt FROM emp e JOIN dept d " + + "ON e.dept_id = d.dept_id GROUP BY d.dept_name HAVING e.label = 'x'" + ) should include( + "'e.label' qualifies the SELECT-list alias 'label' with table alias 'e', " + + "but that alias names a renamed column (e.name)" + ) + // on a single index too: the rule is the same one wherever the statement runs + rejection(group + "t.c > 1") should include("'t.c' qualifies the SELECT-list alias 'c'") + } + + it should "keep the generic reason where the qualified name may be the table's own column" in { + val generic = "can only filter on a GROUP BY key or on an aggregate" + Seq( + // not a SELECT-list alias at all + joinedAgg + "e.name = 'x'", + // the alias of that very column + "SELECT d.dept_name, e.name AS name, COUNT(*) AS cnt FROM emp e JOIN dept d " + + "ON e.dept_id = d.dept_id GROUP BY d.dept_name HAVING e.name = 'x'", + // the alias of ANOTHER table's plain column: `e` may own `id` as well + "SELECT d.dept_id AS id, COUNT(*) AS cnt FROM emp e JOIN dept d " + + "ON e.dept_id = d.dept_id GROUP BY d.dept_id HAVING e.id > 1", + // written as a column elsewhere in the statement + "SELECT d.dept_name, COUNT(*) AS salary FROM emp e JOIN dept d ON e.dept_id = d.dept_id " + + "WHERE e.salary > 0 GROUP BY d.dept_name HAVING e.salary > 1" + ).foreach { sql => + val msg = rejection(sql) + withClue(s"[$sql] msg=[$msg] ") { + msg should include(generic) + msg should not include "qualifies the SELECT-list alias" + } + } + } + // ------------------------------------------------------------------------------------------- // The include/exclude CHANNEL -- it unions, it never intersects (rule b2) //