diff --git a/e2e/questdb b/e2e/questdb
index 46c13d48c..e3d7dd2ef 160000
--- a/e2e/questdb
+++ b/e2e/questdb
@@ -1 +1 @@
-Subproject commit 46c13d48c8338b5c72e95becba97b0ad0423c0f5
+Subproject commit e3d7dd2ef99c209b23746dcbc11faecb8a9e657d
diff --git a/e2e/tests/console/tableDetails.spec.js b/e2e/tests/console/tableDetails.spec.js
index 41c422531..0e705d7ec 100644
--- a/e2e/tests/console/tableDetails.spec.js
+++ b/e2e/tests/console/tableDetails.spec.js
@@ -1186,4 +1186,63 @@ describe("TableDetailsDrawer", () => {
cy.dropTable(TEST_TABLE_2)
})
})
+
+ describe("table with STORAGE POLICY", () => {
+ before(() => {
+ cy.loadConsoleWithAuth()
+ cy.createTable(TEST_TABLE)
+ cy.refreshSchema()
+ })
+
+ beforeEach(() => {
+ cy.intercept(
+ {
+ method: "GET",
+ pathname: "/exec",
+ query: { query: /SHOW\s+CREATE/i },
+ },
+ (req) => {
+ req.continue((res) => {
+ const row = res.body?.dataset?.[0]
+ if (row && typeof row[0] === "string") {
+ row[0] = row[0].replace(
+ /(\bPARTITION\s+BY\s+\w+)/i,
+ "$1 STORAGE POLICY(TO PARQUET 3 DAYS, DROP NATIVE 10 DAYS, DROP LOCAL 1 YEARS)",
+ )
+ }
+ })
+ },
+ ).as("showCreate")
+
+ cy.loadConsoleWithAuth()
+ cy.expandTables()
+ })
+
+ it("hides TTL and renders the storage policy section", () => {
+ cy.openDetailsDrawer(TEST_TABLE)
+ cy.getByDataHook("table-details-tab-details").click()
+
+ cy.getByDataHook("table-details-storage-policy-section")
+ .should("be.visible")
+ .within(() => {
+ cy.contains("To Parquet").should("be.visible")
+ cy.contains("3 Days").should("be.visible")
+ cy.contains("Drop Native").should("be.visible")
+ cy.contains("10 Days").should("be.visible")
+ cy.contains("Drop Local").should("be.visible")
+ cy.contains("1 Year").should("be.visible")
+ })
+
+ cy.getByDataHook("table-details-details-section")
+ .should("be.visible")
+ .within(() => {
+ cy.contains("TTL").should("not.exist")
+ })
+ })
+
+ after(() => {
+ cy.loadConsoleWithAuth()
+ cy.dropTable(TEST_TABLE)
+ })
+ })
})
diff --git a/e2e/tests/enterprise/oidc.spec.js b/e2e/tests/enterprise/oidc.spec.js
index 37fc4a7c6..6861dba4b 100644
--- a/e2e/tests/enterprise/oidc.spec.js
+++ b/e2e/tests/enterprise/oidc.spec.js
@@ -94,6 +94,10 @@ describe("OIDC", () => {
cy.wait("@tokens")
cy.getEditor().should("be.visible")
+ cy.window()
+ .its("localStorage")
+ .invoke("getItem", "sso.username.client1")
+ .should("not.be.empty")
cy.reload()
cy.getEditor().should("be.visible")
diff --git a/e2e/tests/enterprise/tableDetails.spec.js b/e2e/tests/enterprise/tableDetails.spec.js
new file mode 100644
index 000000000..01204976d
--- /dev/null
+++ b/e2e/tests/enterprise/tableDetails.spec.js
@@ -0,0 +1,39 @@
+///
+
+const TEST_TABLE = "btc_trades"
+
+describe("TableDetailsDrawer in enterprise", () => {
+ describe("without a STORAGE POLICY shows 'Not configured'", () => {
+ before(() => {
+ cy.loadConsoleWithAuth()
+ cy.createTable(TEST_TABLE)
+ cy.refreshSchema()
+ })
+
+ it("renders the section with the 'Not configured' placeholder", () => {
+ cy.openDetailsDrawer(TEST_TABLE)
+ cy.getByDataHook("table-details-tab-details").click()
+
+ cy.getByDataHook("table-details-storage-policy-section")
+ .should("be.visible")
+ .within(() => {
+ cy.contains("Not configured").should("be.visible")
+ cy.contains("To Parquet").should("not.exist")
+ cy.contains("Drop Native").should("not.exist")
+ cy.contains("Drop Local").should("not.exist")
+ cy.contains("Drop Remote").should("not.exist")
+ })
+
+ cy.getByDataHook("table-details-details-section")
+ .should("be.visible")
+ .within(() => {
+ cy.contains("TTL").should("not.exist")
+ })
+ })
+
+ after(() => {
+ cy.loadConsoleWithAuth()
+ cy.dropTable(TEST_TABLE)
+ })
+ })
+})
diff --git a/package.json b/package.json
index a38c7f530..aab6714d5 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
"@monaco-editor/react": "^4.7.0",
"@phosphor-icons/react": "^2.1.10",
"@popperjs/core": "2.4.2",
- "@questdb/sql-parser": "0.1.11",
+ "@questdb/sql-parser": "0.1.13",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
diff --git a/scripts/run_ent_browser_tests.sh b/scripts/run_ent_browser_tests.sh
index be180f0a6..b82ace03c 100755
--- a/scripts/run_ent_browser_tests.sh
+++ b/scripts/run_ent_browser_tests.sh
@@ -1,8 +1,39 @@
#!/bin/bash -x
# Run it from the 'scripts' subdirectory as:
-# JAVA_HOME= MVN_REPO= ./run_ent_browser_tests.sh
-# Example: JAVA_HOME=/opt/homebrew/opt/openjdk@17 MVN_REPO=/Users/john/.m2/repository ./run_ent_browser_tests.sh
+# ./run_ent_browser_tests.sh [--cached]
+# Java 25 is required (questdb-enterprise maven enforcer needs the java25+
+# profile to activate). The script auto-selects JDK 25 from the system.
+# Override by exporting JAVA_HOME and/or MVN_REPO before running.
+# --cached: reuse the tmp/questdb-enterprise clone and maven build from the
+# previous run (falls back to cloning/building if missing). Always wipes tmp/dbroot.
+
+# Parse args
+CACHED=0
+for arg in "$@"; do
+ case "$arg" in
+ --cached) CACHED=1 ;;
+ esac
+done
+
+# Auto-select JDK 25 if JAVA_HOME isn't already set to one
+if [ -z "$JAVA_HOME" ] || ! "$JAVA_HOME/bin/java" -version 2>&1 | grep -q '"25'; then
+ if [ -x /usr/libexec/java_home ]; then
+ JAVA_HOME=$(/usr/libexec/java_home -v 25 2>/dev/null)
+ fi
+fi
+if [ -z "$JAVA_HOME" ] || [ ! -x "$JAVA_HOME/bin/java" ]; then
+ echo "Error: could not locate JDK 25. Install one (e.g. 'brew install openjdk@25') or set JAVA_HOME." >&2
+ exit 1
+fi
+export JAVA_HOME
+export PATH="$JAVA_HOME/bin:$PATH"
+
+# Default maven local repo
+if [ -z "$MVN_REPO" ]; then
+ MVN_REPO="$HOME/.m2/repository"
+fi
+export MVN_REPO
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
@@ -14,17 +45,29 @@ cd "$UI_DIR"
# Cleanup
rm -rf tmp/dbroot
-rm -rf tmp/questdb-*
+if [ "$CACHED" -eq 0 ]; then
+ rm -rf tmp/questdb-*
+fi
-# Clone questdb-enterprise
-git clone https://github.com/questdb/questdb-enterprise.git tmp/questdb-enterprise
-cd tmp/questdb-enterprise || exit 1
-git submodule init
-git submodule update
-cd ../..
+# Clone questdb-enterprise (skip if cached clone exists)
+if [ -d tmp/questdb-enterprise/.git ]; then
+ echo "Reusing existing tmp/questdb-enterprise checkout"
+else
+ git clone https://github.com/questdb/questdb-enterprise.git tmp/questdb-enterprise
+ cd tmp/questdb-enterprise || exit 1
+ git submodule init
+ git submodule update
+ cd ../..
+fi
-# Build server
-mvn clean package -e -f tmp/questdb-enterprise/pom.xml -DskipTests -P build-ent-binaries 2>&1
+# Build server (skip if cached classes exist)
+ENT_MAIN_CLASS=tmp/questdb-enterprise/questdb-ent/target/classes/com/questdb/EntServerMain.class
+CORE_MAIN_DIR=tmp/questdb-enterprise/questdb/core/target/classes
+if [ "$CACHED" -eq 1 ] && [ -f "$ENT_MAIN_CLASS" ] && [ -d "$CORE_MAIN_DIR" ]; then
+ echo "Reusing existing maven build output"
+else
+ mvn clean package -e -f tmp/questdb-enterprise/pom.xml -DskipTests -P build-ent-binaries 2>&1
+fi
# Create dbroot
mkdir tmp/dbroot
diff --git a/src/scenes/Schema/TableDetailsDrawer/DetailsTab.tsx b/src/scenes/Schema/TableDetailsDrawer/DetailsTab.tsx
index 708a96cde..3637c6864 100644
--- a/src/scenes/Schema/TableDetailsDrawer/DetailsTab.tsx
+++ b/src/scenes/Schema/TableDetailsDrawer/DetailsTab.tsx
@@ -5,6 +5,8 @@ import {
TextColumnsIcon,
ArrowSquareInIcon,
InfoIcon,
+ DatabaseIcon,
+ XCircleIcon,
} from "@phosphor-icons/react"
import { Box, Text, CopyButton } from "../../../components"
import { LiteEditor } from "../../../components/LiteEditor"
@@ -14,7 +16,7 @@ import type {
View,
Column,
} from "../../../utils/questdb/types"
-import { formatTTL } from "./utils"
+import { formatTTL, extractStoragePolicyClauses } from "./utils"
import { ColumnIcon } from "../Row"
import {
Section,
@@ -39,6 +41,7 @@ export interface DetailsTabProps {
ddl: string
isMatView: boolean
isView: boolean
+ isEnterprise: boolean
truncatedDDL: { text: string; grayedOutLines: [number, number] | null }
baseTableStatus: "Valid" | "Suspended" | "Dropped" | null
columnsExpanded: boolean
@@ -92,19 +95,10 @@ const BaseTableLinkButton = styled.button<{ $disabled?: boolean }>`
}
`
-const DetailsGrid = styled.div`
+const MetricsGrid = styled.div<{ $columns: number }>`
width: 100%;
display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 0.2rem;
- border-radius: 0.5rem;
- overflow: hidden;
-`
-
-const MetricsGrid = styled.div`
- width: 100%;
- display: grid;
- grid-template-columns: repeat(2, 1fr);
+ grid-template-columns: repeat(${({ $columns }) => $columns}, 1fr);
gap: 0.2rem;
border-radius: 0.5rem;
overflow: hidden;
@@ -121,6 +115,15 @@ const MetricCard = styled(Box).attrs<{ $background?: string }>({
$background ?? theme.color.backgroundLighter};
`
+const StoragePolicyClauses = styled.div<{ $columns: number }>`
+ width: 100%;
+ display: grid;
+ grid-template-columns: repeat(${({ $columns }) => $columns}, 1fr);
+ gap: 0.2rem;
+ border-radius: 0.5rem;
+ overflow: hidden;
+`
+
const MetricLabel = styled(Text).attrs({
color: "gray2",
size: "sm",
@@ -168,6 +171,7 @@ export const DetailsTab = ({
ddl,
isMatView,
isView,
+ isEnterprise,
truncatedDDL,
baseTableStatus,
columnsExpanded,
@@ -180,6 +184,10 @@ export const DetailsTab = ({
const theme = useTheme()
const baseTableExists =
baseTableStatus === "Valid" || baseTableStatus === "Suspended"
+ const storagePolicyClauses = extractStoragePolicyClauses(ddl)
+ const hasStoragePolicy = storagePolicyClauses.length > 0
+ const hasTtl = tableData.ttlValue !== 0
+ const showStoragePolicySection = isEnterprise || hasStoragePolicy
return (
<>
@@ -325,14 +333,16 @@ export const DetailsTab = ({
{isMatView && matViewData ? (
- /* Matview: 2 rows × 2 columns */
-
-
- TTL
-
- {formatTTL(tableData.ttlValue, tableData.ttlUnit)}
-
-
+ /* Matview: 4 cards (2×2) when TTL is configured, 3 cards (1 row) when not. */
+
+ {hasTtl && (
+
+ TTL
+
+ {formatTTL(tableData.ttlValue, tableData.ttlUnit)}
+
+
+ )}
Deduplication
@@ -357,14 +367,16 @@ export const DetailsTab = ({
) : (
- /* Table: 3 items in single row */
-
-
- TTL
-
- {formatTTL(tableData.ttlValue, tableData.ttlUnit)}
-
-
+ /* Table: 3 cards (1 row) when TTL is configured, 2 cards (1 row) when not. */
+
+ {hasTtl && (
+
+ TTL
+
+ {formatTTL(tableData.ttlValue, tableData.ttlUnit)}
+
+
+ )}
Deduplication
@@ -380,7 +392,34 @@ export const DetailsTab = ({
tableData.partitionBy.slice(1).toLowerCase()}
-
+
+ )}
+
+ )}
+
+ {!isView && showStoragePolicySection && (
+
+
+
+ Storage policy
+
+ {hasStoragePolicy ? (
+
+ {storagePolicyClauses.map((clause) => (
+
+ {clause.action}
+ {clause.duration}
+
+ ))}
+
+ ) : (
+
+
+ Not configured
+
)}
)}
diff --git a/src/scenes/Schema/TableDetailsDrawer/index.tsx b/src/scenes/Schema/TableDetailsDrawer/index.tsx
index 09ab3f3b4..c35bbdece 100644
--- a/src/scenes/Schema/TableDetailsDrawer/index.tsx
+++ b/src/scenes/Schema/TableDetailsDrawer/index.tsx
@@ -23,7 +23,7 @@ import {
truncateLongDDL,
} from "../../../components/LiteEditor/utils"
import { CircleNotchSpinner } from "../../Editor/Monaco/icons"
-import { QuestContext } from "../../../providers"
+import { QuestContext, useSettings } from "../../../providers"
import * as QuestDB from "../../../utils/questdb"
import {
getTableKind,
@@ -234,6 +234,8 @@ export const TableDetailsDrawer = () => {
)
const { quest } = useContext(QuestContext)
+ const { settings } = useSettings()
+ const isEnterprise = settings["release.type"] === "EE"
const theme = useTheme()
const [tableData, setTableData] = useState(null)
const [matViewData, setMatViewData] = useState(null)
@@ -790,6 +792,7 @@ export const TableDetailsDrawer = () => {
ddl={ddl}
isMatView={isMatView}
isView={isView}
+ isEnterprise={isEnterprise}
truncatedDDL={truncatedDDL}
baseTableStatus={baseTableStatus}
columnsExpanded={columnsExpanded}
diff --git a/src/scenes/Schema/TableDetailsDrawer/utils.ts b/src/scenes/Schema/TableDetailsDrawer/utils.ts
index 56d2cc0fb..56b0ce306 100644
--- a/src/scenes/Schema/TableDetailsDrawer/utils.ts
+++ b/src/scenes/Schema/TableDetailsDrawer/utils.ts
@@ -1,4 +1,5 @@
import { formatDistance } from "date-fns"
+import { parseOne, type StoragePolicy } from "@questdb/sql-parser"
import { fetchUserLocale, getLocaleFromLanguage } from "../../../utils"
export function formatRelativeTimestamp(timestamp: string | null): string {
@@ -39,6 +40,36 @@ export function formatTTL(value: number, unit: string): string {
return `${value} ${unit}`
}
+export type StoragePolicyClause = { action: string; duration: string }
+
+const STORAGE_POLICY_LABELS = [
+ ["toParquet", "To Parquet"],
+ ["dropNative", "Drop Native"],
+ ["dropLocal", "Drop Local"],
+ ["dropRemote", "Drop Remote"],
+] as const
+
+export function extractStoragePolicyClauses(
+ ddl: string,
+): StoragePolicyClause[] {
+ let stmt: { storagePolicy?: StoragePolicy } | undefined
+ try {
+ stmt = parseOne(ddl) as { storagePolicy?: StoragePolicy }
+ } catch {
+ return []
+ }
+ const policy = stmt?.storagePolicy
+ if (!policy) return []
+ return STORAGE_POLICY_LABELS.flatMap(([key, label]) => {
+ const v = policy[key]
+ if (!v) return []
+ const unit = v.unit.charAt(0).toUpperCase() + v.unit.slice(1).toLowerCase()
+ const normalizedUnit =
+ v.value === 1 ? (unit.endsWith("s") ? unit.slice(0, -1) : unit) : unit
+ return [{ action: label, duration: `${v.value} ${normalizedUnit}` }]
+ })
+}
+
export type MetricType = "count" | "p50" | "p90" | "p99" | "max"
export const METRIC_OPTIONS: { label: string; value: MetricType }[] = [
diff --git a/src/utils/generateMatViewDDL.test.ts b/src/utils/generateMatViewDDL.test.ts
index ff8ca8911..43108c740 100644
--- a/src/utils/generateMatViewDDL.test.ts
+++ b/src/utils/generateMatViewDDL.test.ts
@@ -33,8 +33,8 @@ describe("generateMatViewDDL", () => {
const result = generateMatViewDDL(ddl)
expect(result).toMatch(/SAMPLE BY 5m/i)
expect(result).toMatch(/PARTITION BY MONTH/i)
- // Source TTL 3 DAYS → next TTL-ladder rung is 7 DAYS.
- expect(result).toMatch(/TTL\s+7\s+DAYS/i)
+ // Source TTL 3 DAYS is below MONTH partition unit → floored to 1M, bumped → 1 YEARS.
+ expect(result).toMatch(/TTL\s+1\s+YEARS/i)
expect(result).not.toMatch(/bids/i)
expect(result).not.toMatch(/asks/i)
})
@@ -178,17 +178,17 @@ describe("generateMatViewDDL", () => {
it("handles GEOHASH columns via last()", () => {
const ddl = `CREATE TABLE 'sometable2' (
timestamp TIMESTAMP,
- emre INT,
- berk BOOLEAN,
- kaya GEOHASH(5c)
+ foo INT,
+ bar BOOLEAN,
+ baz GEOHASH(5c)
) timestamp(timestamp) PARTITION BY DAY TTL 5 DAYS;`
const result = generateMatViewDDL(ddl)
- expect(result).toMatch(/last\(\s*kaya\s*\)\s+AS\s+last_kaya/i)
- expect(result).toMatch(/last\(\s*emre\s*\)/i)
- expect(result).toMatch(/last\(\s*berk\s*\)/i)
- // Source TTL 5 DAYS → next TTL-ladder rung is 7 DAYS.
- expect(result).toMatch(/TTL\s+7\s+DAYS/i)
+ expect(result).toMatch(/last\(\s*baz\s*\)\s+AS\s+last_baz/i)
+ expect(result).toMatch(/last\(\s*foo\s*\)/i)
+ expect(result).toMatch(/last\(\s*bar\s*\)/i)
+ // Source TTL 5 DAYS is below MONTH partition unit → floored to 1M, bumped → 1 YEARS.
+ expect(result).toMatch(/TTL\s+1\s+YEARS/i)
})
it("handles non-designated TIMESTAMP / TIMESTAMP_NS via last()", () => {
@@ -613,8 +613,21 @@ describe("generateMatViewDDL", () => {
})
describe("TTL ladder", () => {
- // TTL of the new mat view = next ladder rung strictly greater than the source TTL.
- const mv = (
+ // TTL of the new mat view = next ladder rung strictly greater than
+ // max(source TTL, new partition unit). The floor avoids a degenerate
+ // "TTL == partition" view that retains only a single partition.
+
+ // Source SAMPLE BY 30s → new SAMPLE BY 1m → new PARTITION BY DAY,
+ // so the visible ladder above 1d is 7d, 1M, 1y.
+ const mvDay = (
+ ttl: string,
+ ) => `CREATE MATERIALIZED VIEW 'src_30s' WITH BASE 'base' AS (
+ SELECT timestamp, last(price) AS price FROM base SAMPLE BY 30s
+ ) PARTITION BY DAY TTL ${ttl};`
+
+ // Source SAMPLE BY 5m → new SAMPLE BY 30m → new PARTITION BY MONTH,
+ // used to verify the floor-at-partition behavior.
+ const mvMonth = (
ttl: string,
) => `CREATE MATERIALIZED VIEW 'src_5m' WITH BASE 'base' AS (
SELECT timestamp, last(price) AS price FROM base SAMPLE BY 5m
@@ -627,28 +640,42 @@ describe("generateMatViewDDL", () => {
expect(generateMatViewDDL(ddl)).not.toMatch(/TTL\s+\d/i)
})
- it("2 HOURS → 6 HOURS", () => {
- expect(generateMatViewDDL(mv("2 HOURS"))).toMatch(/TTL\s+6\s+HOURS/i)
+ it("partition DAY, 1 DAYS → 7 DAYS", () => {
+ expect(generateMatViewDDL(mvDay("1 DAYS"))).toMatch(/TTL\s+7\s+DAYS/i)
+ })
+
+ it("partition DAY, 1 WEEKS (7d) → 1 MONTHS", () => {
+ expect(generateMatViewDDL(mvDay("1 WEEKS"))).toMatch(/TTL\s+1\s+MONTHS/i)
})
- it("1 DAYS → 7 DAYS", () => {
- expect(generateMatViewDDL(mv("1 DAYS"))).toMatch(/TTL\s+7\s+DAYS/i)
+ it("partition DAY, 3 MONTHS → 1 YEARS", () => {
+ expect(generateMatViewDDL(mvDay("3 MONTHS"))).toMatch(/TTL\s+1\s+YEARS/i)
})
- it("1 WEEKS (converted to 7d) → 1 MONTHS", () => {
- expect(generateMatViewDDL(mv("1 WEEKS"))).toMatch(/TTL\s+1\s+MONTHS/i)
+ it("partition DAY, 1 YEARS → 2 YEARS (step in whole years above the cap)", () => {
+ expect(generateMatViewDDL(mvDay("1 YEARS"))).toMatch(/TTL\s+2\s+YEARS/i)
})
- it("3 MONTHS → 1 YEARS", () => {
- expect(generateMatViewDDL(mv("3 MONTHS"))).toMatch(/TTL\s+1\s+YEARS/i)
+ it("partition DAY, 5 YEARS → 6 YEARS", () => {
+ expect(generateMatViewDDL(mvDay("5 YEARS"))).toMatch(/TTL\s+6\s+YEARS/i)
})
- it("1 YEARS → 2 YEARS (step in whole years above the cap)", () => {
- expect(generateMatViewDDL(mv("1 YEARS"))).toMatch(/TTL\s+2\s+YEARS/i)
+ it("partition MONTH floors sub-month source TTL: 2 HOURS → 1 YEARS", () => {
+ expect(generateMatViewDDL(mvMonth("2 HOURS"))).toMatch(/TTL\s+1\s+YEARS/i)
})
- it("5 YEARS → 6 YEARS", () => {
- expect(generateMatViewDDL(mv("5 YEARS"))).toMatch(/TTL\s+6\s+YEARS/i)
+ it("partition MONTH floors sub-month source TTL: 7 DAYS → 1 YEARS", () => {
+ expect(generateMatViewDDL(mvMonth("7 DAYS"))).toMatch(/TTL\s+1\s+YEARS/i)
+ })
+
+ it("partition MONTH, 3 MONTHS → 1 YEARS (source already above partition unit)", () => {
+ expect(generateMatViewDDL(mvMonth("3 MONTHS"))).toMatch(
+ /TTL\s+1\s+YEARS/i,
+ )
+ })
+
+ it("partition DAY floors sub-day source TTL: 2 HOURS → 7 DAYS", () => {
+ expect(generateMatViewDDL(mvDay("2 HOURS"))).toMatch(/TTL\s+7\s+DAYS/i)
})
it("accepts singular-unit TTL from QuestDB's SHOW CREATE output", () => {
@@ -669,4 +696,146 @@ describe("generateMatViewDDL", () => {
expect(generateMatViewDDL(ddl)).toMatch(/TTL\s+2\s+YEARS/i)
})
})
+
+ describe("STORAGE POLICY wins over TTL when source has both", () => {
+ it("source has STORAGE POLICY only → matview has STORAGE POLICY, no TTL", () => {
+ const ddl = `CREATE TABLE 'trades' (price DOUBLE, ts TIMESTAMP)
+ timestamp(ts) PARTITION BY DAY
+ STORAGE POLICY(TO PARQUET 3 DAYS);`
+ const result = generateMatViewDDL(ddl)
+ expect(result).not.toMatch(/\bTTL\s+\d/i)
+ expect(result).toMatch(/STORAGE\s+POLICY/i)
+ })
+
+ it("source has TTL only → matview has TTL, no STORAGE POLICY", () => {
+ const ddl = `CREATE TABLE 'trades' (price DOUBLE, ts TIMESTAMP)
+ timestamp(ts) PARTITION BY DAY TTL 7 DAYS;`
+ const result = generateMatViewDDL(ddl)
+ // PARTITION BY DAY → matview PARTITION BY MONTH (1h SAMPLE BY rung).
+ // Source 7 DAYS floors to 1 MONTH → bumped → 1 YEARS.
+ expect(result).toMatch(/TTL\s+1\s+YEARS/i)
+ expect(result).not.toMatch(/STORAGE\s+POLICY/i)
+ })
+ })
+
+ describe("STORAGE POLICY propagation", () => {
+ // QuestDB Enterprise (PartitionBy.validateTtlGranularity) requires each
+ // STORAGE POLICY clause to be an integer multiple of the matview partition
+ // size, and toParquet ≤ dropNative ≤ dropLocal ≤ dropRemote. The generator
+ // projects every clause into the matview partition's natural unit (DAYS
+ // for DAY, MONTHS for MONTH, YEARS for YEAR), preserves monotonic order,
+ // and ladder-bumps the terminal clause so the matview outlives the source.
+ // Behavior is edition-agnostic.
+ const tableWithPolicy = (policy: string) => `CREATE TABLE 'trades' (
+ symbol SYMBOL, price DOUBLE, ts TIMESTAMP
+ ) timestamp(ts) PARTITION BY DAY STORAGE POLICY(${policy});`
+
+ it("projects sub-month TO PARQUET to partition unit and ladder-bumps the terminal", () => {
+ // Source PARTITION BY DAY → matview PARTITION BY MONTH (1h SAMPLE BY rung).
+ // 3 DAYS rounds up to 1 MONTHS; terminal bump → next ladder rung → 1 YEARS.
+ const result = generateMatViewDDL(tableWithPolicy("TO PARQUET 3 DAYS"))
+ expect(result).toMatch(
+ /STORAGE\s+POLICY\(\s*TO\s+PARQUET\s+1\s+YEARS\s*\)/i,
+ )
+ })
+
+ it("all four clauses: each projected to MONTH partition, terminal bumped", () => {
+ const result = generateMatViewDDL(
+ tableWithPolicy(
+ "TO PARQUET 3 DAYS, DROP NATIVE 10 DAYS, DROP LOCAL 1 MONTHS, DROP REMOTE 1 YEARS",
+ ),
+ )
+ // 3d, 10d, 1M all round up to 1 MONTHS (≤-ordering allows equal).
+ expect(result).toMatch(/TO\s+PARQUET\s+1\s+MONTHS/i)
+ expect(result).toMatch(/DROP\s+NATIVE\s+1\s+MONTHS/i)
+ expect(result).toMatch(/DROP\s+LOCAL\s+1\s+MONTHS/i)
+ // Terminal 1 YEARS (= 13 partition units after rounding) bumps to 2 YEARS.
+ expect(result).toMatch(/DROP\s+REMOTE\s+2\s+YEARS/i)
+ })
+
+ it("three clauses: non-terminals projected, terminal ladder-bumped", () => {
+ const result = generateMatViewDDL(
+ tableWithPolicy(
+ "TO PARQUET 3 DAYS, DROP NATIVE 10 DAYS, DROP LOCAL 1 MONTHS",
+ ),
+ )
+ expect(result).toMatch(/TO\s+PARQUET\s+1\s+MONTHS/i)
+ expect(result).toMatch(/DROP\s+NATIVE\s+1\s+MONTHS/i)
+ // Terminal 1 MONTHS bumps one rung up the ladder → 1 YEARS.
+ expect(result).toMatch(/DROP\s+LOCAL\s+1\s+YEARS/i)
+ })
+
+ it("non-terminal clauses below the matview partition are all rounded up to 1 partition", () => {
+ // 3d, 10d both round up to 1 MONTHS for a MONTH-partitioned matview.
+ // Verifies compliance with validateTtlGranularity for MONTH partition
+ // (which rejects any hour-based value).
+ const result = generateMatViewDDL(
+ tableWithPolicy(
+ "TO PARQUET 3 DAYS, DROP NATIVE 10 DAYS, DROP LOCAL 1 YEARS",
+ ),
+ )
+ expect(result).toMatch(/PARTITION\s+BY\s+MONTH/i)
+ expect(result).toMatch(/TO\s+PARQUET\s+1\s+MONTHS/i)
+ expect(result).toMatch(/DROP\s+NATIVE\s+1\s+MONTHS/i)
+ // Terminal 1 YEARS (13 partition units after rounding) bumps to 2 YEARS.
+ expect(result).toMatch(/DROP\s+LOCAL\s+2\s+YEARS/i)
+ })
+
+ it("all-1-year clauses: stay at 1 YEARS span, terminal bumps to 2 YEARS", () => {
+ // 1y rounds to 13 partition units (1y = 365d ÷ 30d ≈ 12.16 → ceil 13).
+ // ≤-ordering allows the equal 13-month spans for non-terminals; terminal
+ // bumps off the ladder to 2 YEARS.
+ const result = generateMatViewDDL(
+ tableWithPolicy(
+ "TO PARQUET 1 YEARS, DROP NATIVE 1 YEARS, DROP LOCAL 1 YEARS, DROP REMOTE 1 YEARS",
+ ),
+ )
+ expect(result).toMatch(/TO\s+PARQUET\s+13\s+MONTHS/i)
+ expect(result).toMatch(/DROP\s+NATIVE\s+13\s+MONTHS/i)
+ expect(result).toMatch(/DROP\s+LOCAL\s+13\s+MONTHS/i)
+ expect(result).toMatch(/DROP\s+REMOTE\s+2\s+YEARS/i)
+ })
+
+ it("source with STORAGE POLICY only: TTL absent, clauses projected to partition multiples", () => {
+ const ddl = `CREATE TABLE 'trades' (
+ symbol SYMBOL, price DOUBLE, ts TIMESTAMP
+ ) timestamp(ts) PARTITION BY DAY STORAGE POLICY(TO PARQUET 3 DAYS, DROP NATIVE 10 DAYS);`
+ const result = generateMatViewDDL(ddl)
+ expect(result).not.toMatch(/\bTTL\s+\d/i)
+ expect(result).toMatch(/TO\s+PARQUET\s+1\s+MONTHS/i)
+ // DROP NATIVE is the terminal clause; 1 MONTHS bumps to 1 YEARS.
+ expect(result).toMatch(/DROP\s+NATIVE\s+1\s+YEARS/i)
+ })
+
+ it("source materialized view with STORAGE POLICY propagates to derived matview", () => {
+ const ddl = `CREATE MATERIALIZED VIEW 'src_5m' WITH BASE 'base' AS (
+ SELECT timestamp, last(price) AS price FROM base SAMPLE BY 5m
+ ) PARTITION BY MONTH STORAGE POLICY(TO PARQUET 7 DAYS, DROP NATIVE 14 DAYS);`
+ const result = generateMatViewDDL(ddl)
+ // Matview partition stays MONTH (5m → 30m → MONTH). Both clauses round
+ // up to 1 MONTHS; terminal bumps to 1 YEARS.
+ expect(result).toMatch(/TO\s+PARQUET\s+1\s+MONTHS/i)
+ expect(result).toMatch(/DROP\s+NATIVE\s+1\s+YEARS/i)
+ })
+
+ it("source has no STORAGE POLICY → output has none", () => {
+ const ddl = `CREATE TABLE 'trades' (
+ symbol SYMBOL, price DOUBLE, ts TIMESTAMP
+ ) timestamp(ts) PARTITION BY DAY;`
+ expect(generateMatViewDDL(ddl)).not.toMatch(/STORAGE\s+POLICY/i)
+ })
+
+ it("user repro: PARTITION BY DAY source with mixed-unit policy projects to MONTH matview", () => {
+ // Regression test for the failure: QuestDB Enterprise rejected
+ // `PARTITION BY MONTH STORAGE POLICY(TO PARQUET 3 DAYS, ...)` because
+ // day-based values aren't integer multiples of MONTH.
+ const ddl = `CREATE TABLE 'abc' (col1 DOUBLE, ts TIMESTAMP) timestamp(ts) PARTITION BY DAY STORAGE POLICY(TO PARQUET 3 DAYS, DROP NATIVE 10 DAYS, DROP LOCAL 1 YEARS);`
+ const result = generateMatViewDDL(ddl)
+ expect(result).toMatch(/PARTITION\s+BY\s+MONTH/i)
+ // Every clause must be a months-based value (validateTtlGranularity).
+ expect(result).not.toMatch(
+ /STORAGE\s+POLICY[^)]*\d+\s+(?:HOURS|DAYS|WEEKS)\b/i,
+ )
+ })
+ })
})
diff --git a/src/utils/generateMatViewDDL.ts b/src/utils/generateMatViewDDL.ts
index 77607215f..0841497ba 100644
--- a/src/utils/generateMatViewDDL.ts
+++ b/src/utils/generateMatViewDDL.ts
@@ -11,12 +11,13 @@ import {
type ColumnDefinition,
type SampleByClause,
type MaterializedViewRefresh,
+ type StoragePolicy,
} from "@questdb/sql-parser"
import { formatSql } from "./formatSql"
-// Aggregates kept verbatim in the chain (first arg → layer-1 alias, trailing
-// args pass through). Anything outside this set falls back to `last(alias)`.
-// `count` is handled separately: count() → sum(alias); count(DISTINCT …) → dropped.
+type TTLUnit = "HOURS" | "DAYS" | "WEEKS" | "MONTHS" | "YEARS"
+type TTLAst = { value: number; unit: TTLUnit }
+
const PRESERVED_AGGREGATES = new Set([
"min",
"max",
@@ -80,7 +81,6 @@ const NUMERIC_TYPES = new Set([
"DECIMAL",
])
-// Types where QuestDB has no matching `last()` overload, so we have to skip.
const EXCLUDED_TYPES = new Set(["BINARY", "LONG128", "INTERVAL"])
const LAST_TYPES = new Set([
@@ -96,10 +96,6 @@ const LAST_TYPES = new Set([
"IPV4",
])
-// GEOHASH columns are typed as `GEOHASH()` so they need a prefix check.
-const isLastType = (dataType: string): boolean =>
- LAST_TYPES.has(dataType) || dataType.startsWith("GEOHASH")
-
const SAMPLE_BY_MAP: Record = {
HOUR: "5m",
DAY: "1h",
@@ -109,7 +105,6 @@ const SAMPLE_BY_MAP: Record = {
NONE: "1h",
}
-// Above 1y we step in whole-year increments (1y → 2y → 3y …) via YEAR_RE.
const INTERVAL_LADDER = [
"1s",
"5s",
@@ -125,11 +120,8 @@ const INTERVAL_LADDER = [
"1y",
] as const
-// TTL ladder = INTERVAL_LADDER trimmed to ≥ 1h.
const TTL_LADDER = ["1h", "6h", "1d", "7d", "1M", "1y"] as const
-// Default partitioning per docs/concepts/materialized-views.md:
-// SAMPLE BY > 1h → YEAR, > 1m → MONTH.
const PARTITION_BY_FOR_SAMPLE: Record<
string,
CreateMaterializedViewStatement["partitionBy"]
@@ -153,7 +145,7 @@ const UNIT_SECONDS: Record = {
m: 60,
h: 60 * 60,
d: 24 * 60 * 60,
- // Approximate — used only to order intervals against the ladder, never for time math.
+ // Approximations — used only to order intervals against the ladder.
M: 30 * 24 * 60 * 60,
y: 365 * 24 * 60 * 60,
}
@@ -161,6 +153,38 @@ const UNIT_SECONDS: Record = {
const INTERVAL_RE = /^(\d+)([smhdMy])$/
const YEAR_RE = /^(\d+)y$/
+const TTL_UNIT_TO_LETTER: Partial> = {
+ HOURS: "h",
+ DAYS: "d",
+ MONTHS: "M",
+ YEARS: "y",
+}
+
+const TTL_LETTER_TO_UNIT: Record = {
+ h: "HOURS",
+ d: "DAYS",
+ M: "MONTHS",
+ y: "YEARS",
+}
+
+const PARTITION_INFO: Partial<
+ Record
+> = {
+ DAY: { interval: "1d", ttlUnit: "DAYS" },
+ MONTH: { interval: "1M", ttlUnit: "MONTHS" },
+ YEAR: { interval: "1y", ttlUnit: "YEARS" },
+}
+
+const STORAGE_POLICY_PIPELINE_ORDER = [
+ "toParquet",
+ "dropNative",
+ "dropLocal",
+ "dropRemote",
+] as const
+
+const HEADER =
+ "-- Review SAMPLE BY, PARTITION BY, TTL, refresh clause, and aggregates before running."
+
const escapeRegExp = (s: string): string =>
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
@@ -185,7 +209,6 @@ const nextOnLadder = (
const rungSec = toSeconds(rung)
if (rungSec != null && rungSec > srcSec) return rung
}
- // Past the top of the ladder via a non-year unit (e.g. 400d) → smallest Ny strictly > source.
const years = Math.floor(srcSec / UNIT_SECONDS.y) + 1
return `${years}y`
}
@@ -196,23 +219,6 @@ const nextInterval = (current: string): string =>
const nextTTL = (current: string): string =>
nextOnLadder(current, TTL_LADDER, "1h")
-type TTLUnit = "HOURS" | "DAYS" | "WEEKS" | "MONTHS" | "YEARS"
-type TTLAst = { value: number; unit: TTLUnit }
-
-const TTL_UNIT_TO_LETTER: Partial> = {
- HOURS: "h",
- DAYS: "d",
- MONTHS: "M",
- YEARS: "y",
-}
-
-const TTL_LETTER_TO_UNIT: Record = {
- h: "HOURS",
- d: "DAYS",
- M: "MONTHS",
- y: "YEARS",
-}
-
const ttlToLadderString = (ttl: TTLAst): string | null => {
if (ttl.unit === "WEEKS") return `${ttl.value * 7}d`
const letter = TTL_UNIT_TO_LETTER[ttl.unit]
@@ -227,15 +233,15 @@ const ladderStringToTTL = (s: string): TTLAst | null => {
return { value: Number(m[1]), unit }
}
-// Returns null when source had no TTL — we never invent one.
-const deriveNextTTL = (src: TTLAst | undefined): TTLAst | null => {
- if (!src) return null
- const srcStr = ttlToLadderString(src)
- if (!srcStr) return null
- const nextStr = nextTTL(srcStr)
- return ladderStringToTTL(nextStr)
+const ttlSeconds = (ttl: TTLAst): number | null => {
+ const s = ttlToLadderString(ttl)
+ return s ? toSeconds(s) : null
}
+const partitionInfo = (
+ partition: CreateMaterializedViewStatement["partitionBy"],
+) => (partition ? PARTITION_INFO[partition] : undefined)
+
const partitionFor = (
interval: string,
): CreateMaterializedViewStatement["partitionBy"] => {
@@ -245,32 +251,98 @@ const partitionFor = (
return "MONTH"
}
-// Pass `srcInterval = ""` for tables (no SAMPLE BY) — skips the embedded-replace branch.
-const deriveNextName = (
- srcName: string,
- srcInterval: string,
- newInterval: string,
-): string => {
- // Trailing period suffix, optionally with collision counter: `_5m`, `_5m_2`, `_2y`.
- const trailing = /_(\d+(?:s|m|h|d|M|y))(_\d+)?$/
- if (trailing.test(srcName)) {
- return srcName.replace(trailing, `_${newInterval}`)
- }
- if (srcInterval) {
- const embedded = new RegExp(`(^|_)${escapeRegExp(srcInterval)}(?=_|$)`)
- if (embedded.test(srcName)) {
- return srcName.replace(
- embedded,
- (_m, pre: string) => `${pre}${newInterval}`,
- )
+const deriveNextTTL = (
+ src: TTLAst | undefined,
+ partition: CreateMaterializedViewStatement["partitionBy"],
+): TTLAst | null => {
+ if (!src) return null
+ const srcStr = ttlToLadderString(src)
+ if (!srcStr) return null
+
+ const info = partitionInfo(partition)
+ const srcSec = toSeconds(srcStr)
+ const partSec = info ? toSeconds(info.interval) : null
+ const baseline =
+ info && srcSec != null && partSec != null && partSec > srcSec
+ ? info.interval
+ : srcStr
+
+ return ladderStringToTTL(nextTTL(baseline))
+}
+
+const projectClauseToPartition = (
+ src: TTLAst,
+ partition: CreateMaterializedViewStatement["partitionBy"],
+): TTLAst | null => {
+ const info = partitionInfo(partition)
+ if (!info) return null
+ const partSec = toSeconds(info.interval)
+ const srcSec = ttlSeconds(src)
+ if (partSec == null || srcSec == null) return null
+ const count = Math.max(1, Math.ceil(srcSec / partSec))
+ return { value: count, unit: info.ttlUnit }
+}
+
+const projectStoragePolicyForMatView = (
+ source: StoragePolicy,
+ partition: CreateMaterializedViewStatement["partitionBy"],
+): StoragePolicy | null => {
+ const present = STORAGE_POLICY_PIPELINE_ORDER.filter(
+ (k) => source[k] !== undefined,
+ )
+ if (present.length === 0) return null
+
+ const next: StoragePolicy = { type: "storagePolicy" }
+ const terminal = present[present.length - 1]
+ let prevSec = 0
+ for (const kind of present) {
+ let projected = projectClauseToPartition(source[kind] as TTLAst, partition)
+ if (!projected) continue
+
+ let projectedSec = ttlSeconds(projected) ?? 0
+ while (projectedSec > 0 && projectedSec < prevSec) {
+ projected = { ...projected, value: projected.value + 1 }
+ projectedSec = ttlSeconds(projected) ?? 0
}
+
+ if (kind === terminal) {
+ const bumpedStr = ttlToLadderString(projected)
+ const bumped = bumpedStr ? ladderStringToTTL(nextTTL(bumpedStr)) : null
+ if (bumped) {
+ projected = bumped
+ projectedSec = ttlSeconds(projected) ?? projectedSec
+ }
+ }
+
+ next[kind] = projected
+ prevSec = projectedSec
}
- return `${srcName}_${newInterval}`
+ return next
+}
+
+const projectRetention = (
+ src: { ttl?: TTLAst; storagePolicy?: StoragePolicy },
+ partitionBy: CreateMaterializedViewStatement["partitionBy"],
+): Pick => {
+ const storagePolicy = src.storagePolicy
+ ? (projectStoragePolicyForMatView(src.storagePolicy, partitionBy) ??
+ undefined)
+ : undefined
+ const ttl = storagePolicy
+ ? undefined
+ : (deriveNextTTL(src.ttl, partitionBy) ?? undefined)
+ return { ttl, storagePolicy }
}
const matchesPattern = (name: string, patterns: string[]): boolean =>
patterns.some((p) => name.toLowerCase().includes(p))
+const isLastType = (dataType: string): boolean =>
+ LAST_TYPES.has(dataType) || dataType.startsWith("GEOHASH")
+
+const isExcludedType = (dataType: string): boolean =>
+ dataType.endsWith("[]") || EXCLUDED_TYPES.has(dataType)
+
const mkColumnRef = (name: string): ColumnRef => ({
type: "column",
name: { type: "qualifiedName", parts: [name] },
@@ -291,9 +363,6 @@ const mkSelectItem = (
alias,
})
-const isExcludedType = (dataType: string): boolean =>
- dataType.endsWith("[]") || EXCLUDED_TYPES.has(dataType)
-
const buildSelectItem = (
col: ColumnDefinition,
): ExpressionSelectItem | null => {
@@ -312,21 +381,6 @@ const buildSelectItem = (
return null
}
-const pickUniqueViewName = (
- base: string,
- existingNames: readonly string[],
-): string => {
- const taken = new Set(existingNames.map((n) => n.toLowerCase()))
- if (!taken.has(base.toLowerCase())) return base
- for (let i = 2; ; i++) {
- const candidate = `${base}_${i}`
- if (!taken.has(candidate.toLowerCase())) return candidate
- }
-}
-
-const HEADER =
- "-- Review SAMPLE BY, PARTITION BY, TTL, refresh clause, and aggregates before running."
-
const outputName = (item: ExpressionSelectItem): string | null => {
if (item.alias) return item.alias
const e = item.expression
@@ -338,10 +392,6 @@ const outputName = (item: ExpressionSelectItem): string | null => {
return null
}
-// Chain SELECT items must reference the source mat view's OUTPUT columns
-// (aliases), since the base-table columns no longer exist at this layer.
-// Non-preserved fns fall back to last(). count(DISTINCT …) can't decompose
-// from a scalar, so we drop it entirely (returns null).
const rewriteSelectItemForChain = (
item: ExpressionSelectItem,
): ExpressionSelectItem | null => {
@@ -354,9 +404,9 @@ const rewriteSelectItemForChain = (
if (e.type === "function") {
const fnLower = e.name.toLowerCase()
if (fnLower === "count") {
+ // count(DISTINCT …) can't decompose from a scalar — drop it.
if (e.distinct === true) return null
- // count() / count(*) / count(col) → sum(alias) — sum-of-per-bucket-counts
- // is the correct chained total.
+ // count(…) → sum(alias): sum-of-per-bucket-counts is the chained total.
return {
type: "selectItem",
expression: {
@@ -380,13 +430,51 @@ const rewriteSelectItemForChain = (
return { type: "selectItem", expression: newExpr, alias: out }
}
- // Cast / arithmetic / etc. — layer 1 already materialised it; reference the alias.
return {
type: "selectItem",
expression: mkColumnRef(out),
}
}
+const deriveNextName = (
+ srcName: string,
+ srcInterval: string,
+ newInterval: string,
+): string => {
+ const trailing = /_(\d+(?:s|m|h|d|M|y))(_\d+)?$/
+ if (trailing.test(srcName)) {
+ return srcName.replace(trailing, `_${newInterval}`)
+ }
+ if (srcInterval) {
+ const embedded = new RegExp(`(^|_)${escapeRegExp(srcInterval)}(?=_|$)`)
+ if (embedded.test(srcName)) {
+ return srcName.replace(
+ embedded,
+ (_m, pre: string) => `${pre}${newInterval}`,
+ )
+ }
+ }
+ return `${srcName}_${newInterval}`
+}
+
+const pickUniqueViewName = (
+ base: string,
+ existingNames: readonly string[],
+): string => {
+ const taken = new Set(existingNames.map((n) => n.toLowerCase()))
+ if (!taken.has(base.toLowerCase())) return base
+ for (let i = 2; ; i++) {
+ const candidate = `${base}_${i}`
+ if (!taken.has(candidate.toLowerCase())) return candidate
+ }
+}
+
+const normalizeTTLUnits = (ddl: string): string =>
+ ddl.replace(
+ /\bTTL\s+(\d+)\s+(HOUR|DAY|WEEK|MONTH|YEAR)(?!S)\b/gi,
+ (_m: string, n: string, unit: string) => `TTL ${n} ${unit.toUpperCase()}S`,
+ )
+
const fromTable = (
stmt: CreateTableStatement,
existingNames: readonly string[],
@@ -440,22 +528,16 @@ const fromTable = (
mode: "immediate",
}
+ const partitionBy = PARTITION_BY_FOR_SAMPLE[interval]
const matViewStmt: CreateMaterializedViewStatement = {
type: "createMaterializedView",
- view: {
- type: "qualifiedName",
- parts: [viewName],
- },
+ view: { type: "qualifiedName", parts: [viewName] },
refresh,
query: selectStmt,
asParens: true,
- partitionBy: PARTITION_BY_FOR_SAMPLE[interval],
- }
-
- const nextTtl = deriveNextTTL(stmt.ttl)
- if (nextTtl) matViewStmt.ttl = nextTtl
- if (stmt.ownedBy) {
- matViewStmt.ownedBy = stmt.ownedBy
+ partitionBy,
+ ...projectRetention(stmt, partitionBy),
+ ownedBy: stmt.ownedBy,
}
return `${HEADER}\n${formatSql(toSql(matViewStmt))};`
@@ -495,43 +577,33 @@ const fromMatView = (
table: { type: "qualifiedName", parts: [srcName] },
},
],
- // WHERE / GROUP BY / LATEST ON reference base-table columns that don't
- // exist at the chain layer (and the source mat view already applied them
- // at layer 1).
+ // Source columns referenced by these clauses don't exist at the chain layer.
where: undefined,
groupBy: undefined,
latestOn: undefined,
sampleBy: { ...srcSampleBy, duration: newInterval },
}
+ const partitionBy = partitionFor(newInterval)
const matViewStmt: CreateMaterializedViewStatement = {
type: "createMaterializedView",
view: { type: "qualifiedName", parts: [newName] },
baseTable: { type: "qualifiedName", parts: [srcName] },
+ refresh: src.refresh ?? {
+ type: "materializedViewRefresh",
+ mode: "immediate",
+ },
query: newQuery,
asParens: true,
- partitionBy: partitionFor(newInterval),
+ partitionBy,
+ ...projectRetention(src, partitionBy),
+ period: src.period,
+ ownedBy: src.ownedBy,
}
- // Default to IMMEDIATE so the chain DDL has an explicit refresh clause.
- matViewStmt.refresh = src.refresh ?? {
- type: "materializedViewRefresh",
- mode: "immediate",
- }
- const nextTtl = deriveNextTTL(src.ttl)
- if (nextTtl) matViewStmt.ttl = nextTtl
- if (src.period) matViewStmt.period = src.period
- if (src.ownedBy) matViewStmt.ownedBy = src.ownedBy
-
return `${HEADER}\n${formatSql(toSql(matViewStmt))};`
}
-const normalizeTTLUnits = (ddl: string): string =>
- ddl.replace(
- /\bTTL\s+(\d+)\s+(HOUR|DAY|WEEK|MONTH|YEAR)(?!S)\b/gi,
- (_m: string, n: string, unit: string) => `TTL ${n} ${unit.toUpperCase()}S`,
- )
-
export const generateMatViewDDL = (
ddl: string,
existingNames: readonly string[] = [],
diff --git a/yarn.lock b/yarn.lock
index 39c609be2..c6ed53b4e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2491,12 +2491,12 @@ __metadata:
languageName: node
linkType: hard
-"@questdb/sql-parser@npm:0.1.11":
- version: 0.1.11
- resolution: "@questdb/sql-parser@npm:0.1.11"
+"@questdb/sql-parser@npm:0.1.13":
+ version: 0.1.13
+ resolution: "@questdb/sql-parser@npm:0.1.13"
dependencies:
chevrotain: "npm:11.1.1"
- checksum: 10/5449fa95d2c9a25873b953997782a59cc8a89a3cc56f3cc0dd91657fca76ef6905fbd4f940c7d0215db8d64bc86e013ace2f0145a5e29ef02fe3ac318bee035a
+ checksum: 10/83d9c863abff271745786f7c35d3c72c3d25cf9e82e437e9606c356cd19a6f977515e5c5ab7ad9e3b13d67fb4d49d6e3fb4f49981273e2a47d4b0cc568e4e073
languageName: node
linkType: hard
@@ -2518,7 +2518,7 @@ __metadata:
"@monaco-editor/react": "npm:^4.7.0"
"@phosphor-icons/react": "npm:^2.1.10"
"@popperjs/core": "npm:2.4.2"
- "@questdb/sql-parser": "npm:0.1.11"
+ "@questdb/sql-parser": "npm:0.1.13"
"@radix-ui/react-alert-dialog": "npm:^1.1.15"
"@radix-ui/react-context-menu": "npm:^2.2.16"
"@radix-ui/react-dialog": "npm:^1.1.15"