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
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,10 @@ class TableExecutor(
.map { case (index, mappings) =>
ListMap(
"name" -> index,
"type" -> mappings.tableType.name.toUpperCase,
// BIDC-10a Part D / AD-A-6 — the CLIENT vocabulary, not the stored name.
// `TableType.sqlName` is the single authority; `name` stays the
// `_meta.type` storage key and never reaches a client.
"type" -> mappings.tableType.sqlName,
"pk" -> mappings.primaryKey.mkString(","),
"partitioned" -> mappings.partitionBy
.map(p => s"PARTITION BY ${p.column} (${p.granularity})")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,12 @@ object ResultRenderer {
// Table header
output.append(
s"${emoji("📋")} ${bold(cyan(s"Table: ${table.name}"))} " +
gray(s"[${table.tableType}]") +
// BIDC-10a Part D - the SECOND display projection. This is the `SHOW TABLE <t>` header
// (REPL `\st <table>`), which used to interpolate the case object itself and print
// `[Regular]` - the Scala type name, which no client vocabulary contains and which a
// `tableType.name` search cannot find. It now reads the SAME authority as the `type`
// column of `SHOW TABLES`, so the two cannot disagree inside one REPL session.
gray(s"[${table.tableType.sqlName}]") +
"\n\n"
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright 2025 SOFTNETWORK
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package app.softnetwork.elastic.client

import akka.actor.ActorSystem
import app.softnetwork.elastic.client.result._
import app.softnetwork.elastic.sql.schema.{Table, TableType, TableTypeEnumeration}
import org.scalatest.BeforeAndAfterAll
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.slf4j.{Logger, LoggerFactory}

import scala.collection.immutable.ListMap
import scala.concurrent.duration._

/** Story BIDC-10a Part D (AC 10) — the `type` column of `SHOW TABLES`, end to end, Docker-free.
*
* `TableTypeVocabularySpec` (sql) pins `TableType.sqlName` itself. This one pins the only thing
* that makes it matter: that the projection in `TableExecutor` actually publishes it, starting
* from a mapping whose `_meta.type` carries the STORED name. The same seam serves the JDBC
* `getTables`, the Flight SQL `GET_TABLES` and the REPL, all three of which execute `SHOW TABLES`
* through the gateway.
*/
class ShowTablesTableTypeSpec
extends AnyFlatSpec
with Matchers
with ScalaFutures
with BeforeAndAfterAll {

implicit private val system: ActorSystem = ActorSystem("show-tables-table-type")
override implicit val patienceConfig: PatienceConfig =
PatienceConfig(timeout = scaled(5.seconds))

override def afterAll(): Unit = {
system.terminate()
super.afterAll()
}

/** A mapping exactly as this engine writes it: the STORED `_meta.type` name, never the display
* value. If `TableType.apply` ever stopped reading these, every one of these rows would fall
* back to `Regular` and the expectations below would redden.
*/
private def mappingOf(t: TableType): String =
s"""{"_meta":{"type":"${t.name}"},"properties":{"name":{"type":"keyword"}}}"""

private class StubMappings(mappings: Map[String, String]) extends NopeClientApi {
override protected def logger: Logger = LoggerFactory.getLogger(getClass)
override private[client] def executeGetAllMappings(
indices: Seq[String]
): ElasticResult[Map[String, String]] = ElasticResult.success(mappings)
}

private def showTables(mappings: Map[String, String]): Seq[ListMap[String, Any]] =
new StubMappings(mappings).run("SHOW TABLES").futureValue match {
case ElasticSuccess(QueryRows(rows, _)) => rows
case other => fail(s"expected QueryRows, got $other")
}

/** Derived from the SAME sealed-hierarchy walk `TableTypeVocabularySpec` uses (reachable here
* because `core -> macros -> sql` carries `test->test`), never hand-listed.
*
* A hand-written list would leave THIS spec green when a seventh table type is added while the
* sql-side vocabulary spec goes red - i.e. the projection guard would silently stop covering the
* projection, which is the exact shape of the defect this story closes.
*/
private val allTypes: Seq[TableType] = TableTypeEnumeration.declaredTableTypes

"SHOW TABLES" should "report the client vocabulary for every table type" in {
val indices = allTypes.map(t => s"idx_${t.name}" -> mappingOf(t)).toMap
val byName = showTables(indices).map { r =>
r("name").toString -> r("type").toString
}.toMap

byName shouldBe allTypes.map(t => s"idx_${t.name}" -> t.sqlName).toMap
}

it should "report a plain index as TABLE, never REGULAR" in {
val rows = showTables(Map("orders" -> mappingOf(TableType.Regular)))
rows.map(_("type").toString) shouldBe Seq("TABLE")
}

/** A mapping with no `_meta.type` at all — every index created before table types existed. It
* defaults to `Regular` and must therefore also read as `TABLE`.
*/
it should "report an index with no _meta as TABLE" in {
val rows = showTables(Map("legacy" -> """{"properties":{"name":{"type":"keyword"}}}"""))
rows.map(_("type").toString) shouldBe Seq("TABLE")
}

// -- The SECOND display projection: the `SHOW TABLE <t>` header (REPL `\st <table>`) ----------
//
// `ResultRenderer.renderTableDefinition` interpolated the case object itself and printed
// `[Regular]` - the Scala type name. Both projections now read `TableType.sqlName`, so a REPL
// session cannot show one vocabulary in `SHOW TABLES` and another in `SHOW TABLE`.

private def showTableHeader(t: TableType): String =
ResultRenderer.renderAscii(TableResult(Table("users", Nil, tableType = t)), 1.milli)

"SHOW TABLE" should "head its definition with the same vocabulary for every type" in {
allTypes.foreach { t =>
withClue(s"`SHOW TABLE` header for $t: ") {
showTableHeader(t) should include(s"[${t.sqlName}]")
}
}
}

it should "never head a plain table with the Scala type name" in {
val header = showTableHeader(TableType.Regular)
header should include("[TABLE]")
header should not include "[Regular]"
}

/** The divergence this closes was measurable in the repo's own walkthrough: `SHOW TABLES` and
* `SHOW TABLE` appear 35 lines apart in `documentation/sql/dql_statements.md` and used to
* disagree.
*/
it should "agree with the SHOW TABLES type column" in {
allTypes.foreach { t =>
val listed = showTables(Map("users" -> mappingOf(t))).head("type").toString
withClue(s"SHOW TABLES says `$listed` for $t; SHOW TABLE header: ") {
showTableHeader(t) should include(s"[$listed]")
}
}
}

it should "keep a materialized view distinct from a view" in {
val rows = showTables(
Map(
"orders_mv" -> mappingOf(TableType.MaterializedView),
"orders_v" -> mappingOf(TableType.View)
)
).map(r => r("name").toString -> r("type").toString).toMap

// AD-A-6-SUPERSEDED - SPACED, matching `CREATE MATERIALIZED VIEW`. The JDBC/Flight
// `TABLE_TYPE` contracts collapse this onto `VIEW`; that mapping lives in the drivers.
rows("orders_mv") shouldBe "MATERIALIZED VIEW"
rows("orders_v") shouldBe "VIEW"
}
}
6 changes: 3 additions & 3 deletions documentation/client/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ sql> SHOW TABLES LIKE 'show_%';

| name | type | pk | partitioned |
|------------|---------|----|-------------|
| show_users | REGULAR | id | |
| show_users | TABLE | id | |
📊 1 row(s) (7ms)
```

Expand Down Expand Up @@ -843,7 +843,7 @@ Show detailed table information:
```
sql> \st users

📋 Table: users [Regular]
📋 Table: users [TABLE]
...
```

Expand Down Expand Up @@ -964,7 +964,7 @@ sql> tables

| name | type | pk | partitioned |
|------------|---------|----|-------------|
| demo_users | REGULAR | id | |
| demo_users | TABLE | id | |
📊 1 row(s) (5ms)

sql> \dt demo_users
Expand Down
4 changes: 2 additions & 2 deletions documentation/sql/dql_statements.md
Original file line number Diff line number Diff line change
Expand Up @@ -1312,7 +1312,7 @@ SHOW TABLES LIKE 'show_%';

| name | type | pk | partitioned |
|------------|---------|----|-------------|
| show_users | REGULAR | id | |
| show_users | TABLE | id | |
📊 1 row(s) (7ms)

---
Expand Down Expand Up @@ -1347,7 +1347,7 @@ CREATE TABLE IF NOT EXISTS users (
SHOW TABLE users;
```

📋 Table: users [Regular]
📋 Table: users [TABLE]

| Field | Type | Null | Key | Default | Comment | Script | Extra |
|-------------------|-----------|------|-----|-------------------|-----------------|-------------------------------------------------|---------------------------------------------------|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1655,30 +1655,95 @@ package object schema {
}
}

/** The type of a table, as recorded in the index mapping's `_meta.type`.
*
* Two names, deliberately, because they answer two different questions and only one of them is
* allowed to change:
*
* - `name` is what is STORED. It is written into `_meta.type` and read back by
* `TableType.apply`, so an existing index whose mapping says `"regular"` depends on it byte
* for byte.
* - `sqlName` is what is SHOWN on the ENGINE surface — the `type` column of `SHOW TABLES` and
* the `SHOW TABLE <t>` header, i.e. what the REPL and anything reading `SHOW TABLES` through
* the gateway receives. It must be a table type a SQL user recognises.
*
* ⚠️ `sqlName` is not, by itself, the JDBC/Flight `TABLE_TYPE`. Those drivers map it onto the
* two values their `getTableTypes` advertises (`TABLE` / `VIEW`) — see `MaterializedView` below,
* where that mapping is permanent and load-bearing rather than a temporary shim.
*
* The projection used to be `name.toUpperCase`, which published the internal `REGULAR`.
* Measured: the sidecar's ADBC leg failed `Expected 'TABLE' in ['REGULAR']`. Story BIDC-10a Part
* D.
*
* `sqlName` is ABSTRACT on purpose: a seventh table type cannot compile until someone decides
* what to call it. That decision must not default silently.
*
* ⚠️ EXACTLY ONE place outside storage reads `name`, and it is deliberate: `Table.merge` throws
* `Cannot alter table <t> of type <name>` (below, in this file) when an ALTER targets a
* non-regular table. That message is the ENGINE refusing an operation on its own construct, not
* a catalogue answer to a client, so it legitimately names the engine's own type. It is listed
* here rather than left silent, because the whole point of this split is that no display of a
* table type may be discovered by surprise — that is how `REGULAR` reached clients, and how the
* `SHOW TABLE` header (`ResultRenderer`) went on printing the Scala type name `Regular`
* unnoticed: neither could be found by searching for `tableType.name`.
*
* A new read of `name` outside `_meta.type` and that one error message is a design change, not a
* detail: use `sqlName`, or amend this list.
*/
sealed trait TableType {
def name: String
def sqlName: String
}

object TableType {
case object Regular extends TableType {
override def name: String = "regular"
override def sqlName: String = "TABLE"
}
case object External extends TableType {
override def name: String = "external"
override def sqlName: String = "EXTERNAL"
}
case object Changelog extends TableType {
override def name: String = "changelog"
override def sqlName: String = "CHANGELOG"
}
case object Enrichment extends TableType {
override def name: String = "enrichment"
override def sqlName: String = "ENRICHMENT"
}
case object View extends TableType {
override def name: String = "view"
override def sqlName: String = "VIEW"
}

/** A materialized view keeps its OWN name on the ENGINE surface (`SHOW TABLES`, the REPL)
* rather than collapsing into `VIEW`: it has storage, a refresh schedule and (optionally) a
* watcher, and collapsing it would lose information for the human reading the listing.
*
* 🔴 The spelling is SPACED, and the reason is self-consistency with our own SQL, not any
* other engine's convention. Every statement that names this object is spaced - `CREATE
* MATERIALIZED VIEW`, `SHOW MATERIALIZED VIEW`, `SHOW MATERIALIZED VIEW STATUS`, `SHOW CREATE
* MATERIALIZED VIEW`, `DESCRIBE MATERIALIZED VIEW` (`sql/.../query/package.scala`). An
* underscore here would be the only place the product spells its own object differently from
* the statement that creates it.
*
* ⚠️ This is NOT what a JDBC or Flight SQL client sees. Those `TABLE_TYPE` contracts report
* `VIEW` for a materialized view, because `getTableTypes` advertises exactly `TABLE` and
* `VIEW` and `getTables` filters by EXACT string match - so a third value, with the advertised
* list unchanged, makes every materialized view vanish from an object browser that asks for
* the advertised types. The drivers own that collapse; it is PERMANENT and load-bearing, not a
* shim to be cleaned up. Story BIDC-10a, AD-A-6-SUPERSEDED.
*/
case object MaterializedView extends TableType {
override def name: String = "materialized_view"
override def sqlName: String = "MATERIALIZED VIEW"
}

/** Parses the STORED name (`_meta.type`), never the display `sqlName`. Widening it to accept
* `"TABLE"` would make two spellings of the same type storable and is not what any caller
* needs: every caller reads a mapping this engine wrote.
*/
def apply(name: String): TableType =
name.toLowerCase match {
case "regular" => Regular
Expand Down Expand Up @@ -1847,6 +1912,9 @@ package object schema {

def merge(statements: Seq[AlterTableStatement]): Table = {
if (!isRegular)
// BIDC-10a Part D - the deliberate exception recorded in TableType's scaladoc: this is the
// engine refusing an operation on its own construct, so it names the STORED type. Every
// client-facing rendering of a table type uses `TableType.sqlName` instead.
throw new Exception(s"Cannot alter table $name of type ${tableType.name}")
statements
.foldLeft(this) { (table, statement) =>
Expand Down
Loading
Loading