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"