@bonnard/cli
Advanced tools
| import { i as getProjectPaths } from "./bon.mjs"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| //#region src/lib/cubes/datasources.ts | ||
| /** | ||
| * Extract datasource references from cube and view files | ||
| */ | ||
| /** | ||
| * Collect all YAML files from a directory recursively | ||
| */ | ||
| function collectYamlFiles(dir) { | ||
| if (!fs.existsSync(dir)) return []; | ||
| const results = []; | ||
| function walk(current) { | ||
| for (const entry of fs.readdirSync(current, { withFileTypes: true })) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (entry.isDirectory()) walk(fullPath); | ||
| else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push(fullPath); | ||
| } | ||
| } | ||
| walk(dir); | ||
| return results; | ||
| } | ||
| /** | ||
| * Parse a single model file and extract datasource references | ||
| */ | ||
| function extractFromFile(filePath) { | ||
| const datasourceToCubes = /* @__PURE__ */ new Map(); | ||
| try { | ||
| const content = fs.readFileSync(filePath, "utf-8"); | ||
| const parsed = YAML.parse(content); | ||
| if (!parsed) return datasourceToCubes; | ||
| if (parsed.cubes) for (const cube of parsed.cubes) { | ||
| const ds = cube.data_source || "default"; | ||
| const existing = datasourceToCubes.get(ds) || []; | ||
| existing.push(cube.name); | ||
| datasourceToCubes.set(ds, existing); | ||
| } | ||
| if (parsed.views) { | ||
| for (const view of parsed.views) if (view.data_source) { | ||
| const existing = datasourceToCubes.get(view.data_source) || []; | ||
| existing.push(view.name); | ||
| datasourceToCubes.set(view.data_source, existing); | ||
| } | ||
| } | ||
| } catch {} | ||
| return datasourceToCubes; | ||
| } | ||
| /** | ||
| * Extract all unique datasource references from bonnard/cubes/ and bonnard/views/ directories | ||
| * Returns datasource names mapped to the cubes that use them | ||
| */ | ||
| function extractDatasourcesFromCubes(projectPath) { | ||
| const paths = getProjectPaths(projectPath); | ||
| const cubesDir = paths.cubes; | ||
| const viewsDir = paths.views; | ||
| const allFiles = [...collectYamlFiles(cubesDir), ...collectYamlFiles(viewsDir)]; | ||
| const aggregated = /* @__PURE__ */ new Map(); | ||
| for (const file of allFiles) { | ||
| const fileRefs = extractFromFile(file); | ||
| for (const [ds, cubes] of fileRefs) { | ||
| const existing = aggregated.get(ds) || []; | ||
| existing.push(...cubes); | ||
| aggregated.set(ds, existing); | ||
| } | ||
| } | ||
| const results = []; | ||
| for (const [name, cubes] of aggregated) if (name !== "default") results.push({ | ||
| name, | ||
| cubes | ||
| }); | ||
| return results; | ||
| } | ||
| //#endregion | ||
| export { extractDatasourcesFromCubes }; |
| import { n as resolveEnvVarsInCredentials, r as post, t as getLocalDatasource } from "./bon.mjs"; | ||
| import pc from "picocolors"; | ||
| import { confirm } from "@inquirer/prompts"; | ||
| //#region src/commands/datasource/push.ts | ||
| /** | ||
| * Push a datasource programmatically (for use by deploy command) | ||
| * Returns true on success, false on failure | ||
| */ | ||
| async function pushDatasource(name, options = {}) { | ||
| const datasource = getLocalDatasource(name); | ||
| if (!datasource) { | ||
| if (!options.silent) console.error(pc.red(`Datasource "${name}" not found locally`)); | ||
| return false; | ||
| } | ||
| const { resolved, missing } = resolveEnvVarsInCredentials(datasource.credentials); | ||
| if (missing.length > 0) { | ||
| if (!options.silent) console.error(pc.red(`Missing env vars for "${name}": ${missing.join(", ")}`)); | ||
| return false; | ||
| } | ||
| try { | ||
| await post("/api/datasources", { | ||
| name: datasource.name, | ||
| warehouse_type: datasource.type, | ||
| config: datasource.config, | ||
| credentials: resolved | ||
| }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { pushDatasource }; |
| import { i as getProjectPaths } from "./bon.mjs"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| import { z } from "zod"; | ||
| //#region src/lib/schema.ts | ||
| const identifier = z.string().regex(/^[_a-zA-Z][_a-zA-Z0-9]*$/, "must be a valid identifier (letters, numbers, underscores; cannot start with a number)"); | ||
| const refreshKeySchema = z.object({ | ||
| every: z.string().regex(/^\d+\s+(second|minute|hour|day|week)s?$/, { message: "must be a time interval like \"1 hour\", \"30 minute\", \"1 day\"" }).optional(), | ||
| sql: z.string().optional() | ||
| }); | ||
| const measureTypes = [ | ||
| "count", | ||
| "count_distinct", | ||
| "count_distinct_approx", | ||
| "sum", | ||
| "avg", | ||
| "min", | ||
| "max", | ||
| "number", | ||
| "string", | ||
| "time", | ||
| "boolean", | ||
| "running_total", | ||
| "number_agg" | ||
| ]; | ||
| const dimensionTypes = [ | ||
| "string", | ||
| "number", | ||
| "boolean", | ||
| "time", | ||
| "geo", | ||
| "switch" | ||
| ]; | ||
| const relationshipTypes = [ | ||
| "many_to_one", | ||
| "one_to_many", | ||
| "one_to_one" | ||
| ]; | ||
| const granularities = [ | ||
| "second", | ||
| "minute", | ||
| "hour", | ||
| "day", | ||
| "week", | ||
| "month", | ||
| "quarter", | ||
| "year" | ||
| ]; | ||
| const preAggTypes = [ | ||
| "rollup", | ||
| "original_sql", | ||
| "rollup_join", | ||
| "rollup_lambda" | ||
| ]; | ||
| const formats = [ | ||
| "percent", | ||
| "currency", | ||
| "number", | ||
| "imageUrl", | ||
| "link", | ||
| "id" | ||
| ]; | ||
| const measureSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(measureTypes), | ||
| sql: z.string().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| format: z.enum(formats).optional(), | ||
| public: z.boolean().optional(), | ||
| filters: z.array(z.object({ sql: z.string() })).optional(), | ||
| rolling_window: z.object({ | ||
| trailing: z.string().optional(), | ||
| leading: z.string().optional(), | ||
| offset: z.string().optional() | ||
| }).optional(), | ||
| drill_members: z.array(z.string()).optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const dimensionFormatSchema = z.union([z.enum(formats), z.string().startsWith("%")]); | ||
| const dimensionSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(dimensionTypes), | ||
| sql: z.string().optional(), | ||
| primary_key: z.boolean().optional(), | ||
| sub_query: z.boolean().optional(), | ||
| propagate_filters_to_sub_query: z.boolean().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| format: dimensionFormatSchema.optional(), | ||
| public: z.boolean().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional(), | ||
| latitude: z.object({ sql: z.string() }).optional(), | ||
| longitude: z.object({ sql: z.string() }).optional(), | ||
| case: z.object({ | ||
| when: z.array(z.object({ | ||
| sql: z.string(), | ||
| label: z.string() | ||
| })), | ||
| else: z.object({ label: z.string() }).optional() | ||
| }).optional() | ||
| }); | ||
| const joinSchema = z.object({ | ||
| name: identifier, | ||
| relationship: z.enum(relationshipTypes), | ||
| sql: z.string() | ||
| }); | ||
| const segmentSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional() | ||
| }); | ||
| const preAggregationSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(preAggTypes).optional(), | ||
| measures: z.array(z.string()).optional(), | ||
| dimensions: z.array(z.string()).optional(), | ||
| time_dimension: z.string().optional(), | ||
| granularity: z.enum(granularities).optional(), | ||
| partition_granularity: z.enum(granularities).optional(), | ||
| refresh_key: refreshKeySchema.optional(), | ||
| scheduled_refresh: z.boolean().optional() | ||
| }); | ||
| const hierarchySchema = z.object({ | ||
| name: identifier, | ||
| levels: z.array(z.string()), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional() | ||
| }); | ||
| const cubeSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string().optional(), | ||
| sql_table: z.string().optional(), | ||
| data_source: z.string().optional(), | ||
| extends: z.string().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional(), | ||
| refresh_key: refreshKeySchema.optional(), | ||
| measures: z.array(measureSchema).optional(), | ||
| dimensions: z.array(dimensionSchema).optional(), | ||
| joins: z.array(joinSchema).optional(), | ||
| segments: z.array(segmentSchema).optional(), | ||
| pre_aggregations: z.array(preAggregationSchema).optional(), | ||
| hierarchies: z.array(hierarchySchema).optional() | ||
| }).refine((data) => data.sql != null || data.sql_table != null || data.extends != null, { message: "sql, sql_table, or extends is required" }); | ||
| const viewIncludeItemSchema = z.union([z.string(), z.object({ | ||
| name: z.string(), | ||
| alias: z.string().optional(), | ||
| title: z.string().optional(), | ||
| description: z.string().optional(), | ||
| format: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| })]); | ||
| const viewCubeRefSchema = z.object({ | ||
| join_path: z.string(), | ||
| includes: z.union([z.literal("*"), z.array(viewIncludeItemSchema)]).optional(), | ||
| excludes: z.array(z.string()).optional(), | ||
| prefix: z.boolean().optional() | ||
| }); | ||
| const folderSchema = z.lazy(() => z.object({ | ||
| name: z.string(), | ||
| members: z.array(z.string()).optional(), | ||
| folders: z.array(folderSchema).optional() | ||
| })); | ||
| const viewMeasureSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| type: z.string(), | ||
| format: z.string().optional(), | ||
| description: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const viewDimensionSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| type: z.string(), | ||
| description: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const viewSegmentSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| description: z.string().optional() | ||
| }); | ||
| const viewSchema = z.object({ | ||
| name: identifier, | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional(), | ||
| cubes: z.array(viewCubeRefSchema).optional(), | ||
| measures: z.array(viewMeasureSchema).optional(), | ||
| dimensions: z.array(viewDimensionSchema).optional(), | ||
| segments: z.array(viewSegmentSchema).optional(), | ||
| folders: z.array(folderSchema).optional() | ||
| }); | ||
| const fileSchema = z.object({ | ||
| cubes: z.array(cubeSchema).optional(), | ||
| views: z.array(viewSchema).optional() | ||
| }).refine((data) => data.cubes && data.cubes.length > 0 || data.views && data.views.length > 0, "File must contain at least one cube or view"); | ||
| function formatZodError(error, fileName, parsed) { | ||
| return error.issues.map((issue) => { | ||
| const pathParts = issue.path; | ||
| let entityContext = ""; | ||
| if (pathParts.length >= 2) { | ||
| const collection = pathParts[0]; | ||
| const index = pathParts[1]; | ||
| const entity = parsed?.[collection]?.[index]; | ||
| if (entity?.name) entityContext = ` (${entity.name})`; | ||
| } | ||
| const pathStr = pathParts.join("."); | ||
| const location = pathStr ? `${pathStr}${entityContext}` : ""; | ||
| if (issue.code === "invalid_value" && "values" in issue) return `${fileName}: ${location} — invalid value, expected one of: ${issue.values.join(", ")}`; | ||
| return `${fileName}: ${location ? `${location} — ` : ""}${issue.message}`; | ||
| }); | ||
| } | ||
| function validateFiles(files) { | ||
| const errors = []; | ||
| const cubes = []; | ||
| const views = []; | ||
| const allNames = /* @__PURE__ */ new Map(); | ||
| for (const file of files) { | ||
| let parsed; | ||
| try { | ||
| parsed = YAML.parse(file.content); | ||
| } catch (err) { | ||
| errors.push(`${file.fileName}: YAML parse error — ${err.message}`); | ||
| continue; | ||
| } | ||
| if (!parsed || typeof parsed !== "object") { | ||
| errors.push(`${file.fileName}: file is empty or not a YAML object`); | ||
| continue; | ||
| } | ||
| const result = fileSchema.safeParse(parsed); | ||
| if (!result.success) { | ||
| errors.push(...formatZodError(result.error, file.fileName, parsed)); | ||
| continue; | ||
| } | ||
| for (const cube of parsed.cubes ?? []) if (cube.name) { | ||
| const existing = allNames.get(cube.name); | ||
| if (existing) errors.push(`${file.fileName}: duplicate name '${cube.name}' (also defined in ${existing})`); | ||
| else { | ||
| allNames.set(cube.name, file.fileName); | ||
| cubes.push(cube.name); | ||
| } | ||
| } | ||
| for (const view of parsed.views ?? []) if (view.name) { | ||
| const existing = allNames.get(view.name); | ||
| if (existing) errors.push(`${file.fileName}: duplicate name '${view.name}' (also defined in ${existing})`); | ||
| else { | ||
| allNames.set(view.name, file.fileName); | ||
| views.push(view.name); | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| errors, | ||
| cubes, | ||
| views | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/validate.ts | ||
| function collectYamlFiles(dir, rootDir) { | ||
| if (!fs.existsSync(dir)) return []; | ||
| const results = []; | ||
| function walk(current) { | ||
| for (const entry of fs.readdirSync(current, { withFileTypes: true })) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (entry.isDirectory()) walk(fullPath); | ||
| else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push({ | ||
| fileName: path.relative(rootDir, fullPath), | ||
| content: fs.readFileSync(fullPath, "utf-8") | ||
| }); | ||
| } | ||
| } | ||
| walk(dir); | ||
| return results; | ||
| } | ||
| function checkMissingDescriptions(files) { | ||
| const missing = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| const cubes = parsed.cubes || []; | ||
| for (const cube of cubes) { | ||
| if (!cube.name) continue; | ||
| if (!cube.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "cube", | ||
| name: cube.name | ||
| }); | ||
| const measures = cube.measures || []; | ||
| for (const measure of measures) if (measure.name && !measure.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "measure", | ||
| name: measure.name | ||
| }); | ||
| const dimensions = cube.dimensions || []; | ||
| for (const dimension of dimensions) if (dimension.name && !dimension.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "dimension", | ||
| name: dimension.name | ||
| }); | ||
| } | ||
| const views = parsed.views || []; | ||
| for (const view of views) { | ||
| if (!view.name) continue; | ||
| if (!view.description) missing.push({ | ||
| parent: view.name, | ||
| type: "view", | ||
| name: view.name | ||
| }); | ||
| } | ||
| } catch {} | ||
| return missing; | ||
| } | ||
| function checkMissingDataSource(files) { | ||
| const missing = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const cube of parsed.cubes || []) if (cube.name && !cube.data_source) missing.push(cube.name); | ||
| } catch {} | ||
| return missing; | ||
| } | ||
| async function validate(projectPath) { | ||
| const paths = getProjectPaths(projectPath); | ||
| const files = [...collectYamlFiles(paths.cubes, projectPath), ...collectYamlFiles(paths.views, projectPath)]; | ||
| if (files.length === 0) return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [] | ||
| }; | ||
| const result = validateFiles(files); | ||
| if (result.errors.length > 0) return { | ||
| valid: false, | ||
| errors: result.errors, | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [] | ||
| }; | ||
| const missingDescriptions = checkMissingDescriptions(files); | ||
| const cubesMissingDataSource = checkMissingDataSource(files); | ||
| return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: result.cubes, | ||
| views: result.views, | ||
| missingDescriptions, | ||
| cubesMissingDataSource | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { validate }; |
@@ -151,3 +151,3 @@ # Deploy | ||
| - Verify network access to database | ||
| - Run: bon datasource test analytics | ||
| - Run: bon datasource add (to reconfigure) | ||
| ``` | ||
@@ -154,0 +154,0 @@ |
@@ -149,3 +149,2 @@ # Workflow | ||
| | `bon datasource list` | List configured sources | | ||
| | `bon datasource test <name>` | Test connection (requires login) | | ||
| | `bon validate` | Check cube and view syntax | | ||
@@ -152,0 +151,0 @@ | `bon deploy -m "message"` | Deploy to Bonnard (message required) | |
@@ -119,3 +119,3 @@ # Validate | ||
| 3. **Fix errors first** — don't deploy with validation errors | ||
| 4. **Test connections** — use `bon datasource test <name>` to check connectivity | ||
| 4. **Test connections** — connections are tested automatically during `bon deploy` | ||
@@ -122,0 +122,0 @@ ## See Also |
@@ -18,26 +18,24 @@ --- | ||
| ```bash | ||
| # Option A: Import from dbt (if they use it) | ||
| # Option A: Use demo data (no warehouse needed) | ||
| bon datasource add --demo | ||
| # Option B: Import from dbt (if they use it) | ||
| bon datasource add --from-dbt | ||
| # Option B: Add manually (interactive) | ||
| # Option C: Add manually, non-interactive (preferred for agents) | ||
| bon datasource add --name my_warehouse --type postgres \ | ||
| --host db.example.com --port 5432 --database mydb --schema public \ | ||
| --user myuser --password mypassword | ||
| # Option D: Add manually, interactive (in user's terminal) | ||
| bon datasource add | ||
| # Option C: Use demo data (no warehouse needed) | ||
| bon datasource add --demo | ||
| ``` | ||
| Supported types: `postgres` (also works for Redshift), `snowflake`, `bigquery`, `databricks`. | ||
| The demo option adds a read-only Contoso retail dataset with tables like | ||
| `fact_sales`, `dim_product`, `dim_store`, and `dim_customer`. | ||
| Then verify the connection works: | ||
| The connection will be tested automatically during `bon deploy`. | ||
| ```bash | ||
| bon datasource test <name> | ||
| ``` | ||
| If the test fails, common issues: | ||
| - Wrong credentials — re-run `bon datasource add` | ||
| - Network/firewall — check warehouse allows connections from this machine | ||
| - SSL issues (Postgres) — may need `sslmode` in connection config | ||
| ## Phase 2: Explore the Data | ||
@@ -44,0 +42,0 @@ |
@@ -51,21 +51,23 @@ --- | ||
| Add a datasource pointing to the same database that Metabase queries: | ||
| Add a datasource pointing to the same database that Metabase queries. | ||
| The database connection details can often be found in Metabase under | ||
| Admin > Databases, or in the analysis report header. | ||
| ```bash | ||
| # Interactive setup | ||
| bon datasource add | ||
| # Non-interactive (preferred for agents) | ||
| bon datasource add --name my_warehouse --type postgres \ | ||
| --host db.example.com --port 5432 --database mydb --schema public \ | ||
| --user myuser --password mypassword | ||
| # Or import from dbt if available | ||
| # Import from dbt if available | ||
| bon datasource add --from-dbt | ||
| # Interactive setup (in user's terminal) | ||
| bon datasource add | ||
| ``` | ||
| Then verify the connection: | ||
| Supported types: `postgres` (also works for Redshift), `snowflake`, `bigquery`, `databricks`. | ||
| ```bash | ||
| bon datasource test <name> | ||
| ``` | ||
| The connection will be tested automatically during `bon deploy`. | ||
| The database connection details can often be found in Metabase under | ||
| Admin > Databases, or in the analysis report header. | ||
| ## Phase 4: Explore Key Tables | ||
@@ -72,0 +74,0 @@ |
@@ -66,3 +66,2 @@ # Bonnard Semantic Layer | ||
| | `bon datasource add --from-dbt` | Import from dbt profiles | | ||
| | `bon datasource test <name>` | Test connection (requires login) | | ||
| | `bon validate` | Validate YAML syntax, warn on missing descriptions and `data_source` | | ||
@@ -69,0 +68,0 @@ | `bon deploy -m "message"` | Deploy to Bonnard (requires login, message required) | |
+1
-1
| { | ||
| "name": "@bonnard/cli", | ||
| "version": "0.2.2", | ||
| "version": "0.2.3", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "bin": { |
| import { t as getProjectPaths } from "./bon.mjs"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| //#region src/lib/cubes/datasources.ts | ||
| /** | ||
| * Extract datasource references from cube and view files | ||
| */ | ||
| /** | ||
| * Collect all YAML files from a directory recursively | ||
| */ | ||
| function collectYamlFiles(dir) { | ||
| if (!fs.existsSync(dir)) return []; | ||
| const results = []; | ||
| function walk(current) { | ||
| for (const entry of fs.readdirSync(current, { withFileTypes: true })) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (entry.isDirectory()) walk(fullPath); | ||
| else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push(fullPath); | ||
| } | ||
| } | ||
| walk(dir); | ||
| return results; | ||
| } | ||
| /** | ||
| * Parse a single model file and extract datasource references | ||
| */ | ||
| function extractFromFile(filePath) { | ||
| const datasourceToCubes = /* @__PURE__ */ new Map(); | ||
| try { | ||
| const content = fs.readFileSync(filePath, "utf-8"); | ||
| const parsed = YAML.parse(content); | ||
| if (!parsed) return datasourceToCubes; | ||
| if (parsed.cubes) for (const cube of parsed.cubes) { | ||
| const ds = cube.data_source || "default"; | ||
| const existing = datasourceToCubes.get(ds) || []; | ||
| existing.push(cube.name); | ||
| datasourceToCubes.set(ds, existing); | ||
| } | ||
| if (parsed.views) { | ||
| for (const view of parsed.views) if (view.data_source) { | ||
| const existing = datasourceToCubes.get(view.data_source) || []; | ||
| existing.push(view.name); | ||
| datasourceToCubes.set(view.data_source, existing); | ||
| } | ||
| } | ||
| } catch {} | ||
| return datasourceToCubes; | ||
| } | ||
| /** | ||
| * Extract all unique datasource references from bonnard/cubes/ and bonnard/views/ directories | ||
| * Returns datasource names mapped to the cubes that use them | ||
| */ | ||
| function extractDatasourcesFromCubes(projectPath) { | ||
| const paths = getProjectPaths(projectPath); | ||
| const cubesDir = paths.cubes; | ||
| const viewsDir = paths.views; | ||
| const allFiles = [...collectYamlFiles(cubesDir), ...collectYamlFiles(viewsDir)]; | ||
| const aggregated = /* @__PURE__ */ new Map(); | ||
| for (const file of allFiles) { | ||
| const fileRefs = extractFromFile(file); | ||
| for (const [ds, cubes] of fileRefs) { | ||
| const existing = aggregated.get(ds) || []; | ||
| existing.push(...cubes); | ||
| aggregated.set(ds, existing); | ||
| } | ||
| } | ||
| const results = []; | ||
| for (const [name, cubes] of aggregated) if (name !== "default") results.push({ | ||
| name, | ||
| cubes | ||
| }); | ||
| return results; | ||
| } | ||
| //#endregion | ||
| export { extractDatasourcesFromCubes }; |
| import { t as getProjectPaths } from "./bon.mjs"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import YAML from "yaml"; | ||
| import { z } from "zod"; | ||
| //#region src/lib/schema.ts | ||
| const identifier = z.string().regex(/^[_a-zA-Z][_a-zA-Z0-9]*$/, "must be a valid identifier (letters, numbers, underscores; cannot start with a number)"); | ||
| const refreshKeySchema = z.object({ | ||
| every: z.string().regex(/^\d+\s+(second|minute|hour|day|week)s?$/, { message: "must be a time interval like \"1 hour\", \"30 minute\", \"1 day\"" }).optional(), | ||
| sql: z.string().optional() | ||
| }); | ||
| const measureTypes = [ | ||
| "count", | ||
| "count_distinct", | ||
| "count_distinct_approx", | ||
| "sum", | ||
| "avg", | ||
| "min", | ||
| "max", | ||
| "number", | ||
| "string", | ||
| "time", | ||
| "boolean", | ||
| "running_total", | ||
| "number_agg" | ||
| ]; | ||
| const dimensionTypes = [ | ||
| "string", | ||
| "number", | ||
| "boolean", | ||
| "time", | ||
| "geo", | ||
| "switch" | ||
| ]; | ||
| const relationshipTypes = [ | ||
| "many_to_one", | ||
| "one_to_many", | ||
| "one_to_one" | ||
| ]; | ||
| const granularities = [ | ||
| "second", | ||
| "minute", | ||
| "hour", | ||
| "day", | ||
| "week", | ||
| "month", | ||
| "quarter", | ||
| "year" | ||
| ]; | ||
| const preAggTypes = [ | ||
| "rollup", | ||
| "original_sql", | ||
| "rollup_join", | ||
| "rollup_lambda" | ||
| ]; | ||
| const formats = [ | ||
| "percent", | ||
| "currency", | ||
| "number", | ||
| "imageUrl", | ||
| "link", | ||
| "id" | ||
| ]; | ||
| const measureSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(measureTypes), | ||
| sql: z.string().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| format: z.enum(formats).optional(), | ||
| public: z.boolean().optional(), | ||
| filters: z.array(z.object({ sql: z.string() })).optional(), | ||
| rolling_window: z.object({ | ||
| trailing: z.string().optional(), | ||
| leading: z.string().optional(), | ||
| offset: z.string().optional() | ||
| }).optional(), | ||
| drill_members: z.array(z.string()).optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const dimensionFormatSchema = z.union([z.enum(formats), z.string().startsWith("%")]); | ||
| const dimensionSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(dimensionTypes), | ||
| sql: z.string().optional(), | ||
| primary_key: z.boolean().optional(), | ||
| sub_query: z.boolean().optional(), | ||
| propagate_filters_to_sub_query: z.boolean().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| format: dimensionFormatSchema.optional(), | ||
| public: z.boolean().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional(), | ||
| latitude: z.object({ sql: z.string() }).optional(), | ||
| longitude: z.object({ sql: z.string() }).optional(), | ||
| case: z.object({ | ||
| when: z.array(z.object({ | ||
| sql: z.string(), | ||
| label: z.string() | ||
| })), | ||
| else: z.object({ label: z.string() }).optional() | ||
| }).optional() | ||
| }); | ||
| const joinSchema = z.object({ | ||
| name: identifier, | ||
| relationship: z.enum(relationshipTypes), | ||
| sql: z.string() | ||
| }); | ||
| const segmentSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional() | ||
| }); | ||
| const preAggregationSchema = z.object({ | ||
| name: identifier, | ||
| type: z.enum(preAggTypes).optional(), | ||
| measures: z.array(z.string()).optional(), | ||
| dimensions: z.array(z.string()).optional(), | ||
| time_dimension: z.string().optional(), | ||
| granularity: z.enum(granularities).optional(), | ||
| partition_granularity: z.enum(granularities).optional(), | ||
| refresh_key: refreshKeySchema.optional(), | ||
| scheduled_refresh: z.boolean().optional() | ||
| }); | ||
| const hierarchySchema = z.object({ | ||
| name: identifier, | ||
| levels: z.array(z.string()), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional() | ||
| }); | ||
| const cubeSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string().optional(), | ||
| sql_table: z.string().optional(), | ||
| data_source: z.string().optional(), | ||
| extends: z.string().optional(), | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional(), | ||
| refresh_key: refreshKeySchema.optional(), | ||
| measures: z.array(measureSchema).optional(), | ||
| dimensions: z.array(dimensionSchema).optional(), | ||
| joins: z.array(joinSchema).optional(), | ||
| segments: z.array(segmentSchema).optional(), | ||
| pre_aggregations: z.array(preAggregationSchema).optional(), | ||
| hierarchies: z.array(hierarchySchema).optional() | ||
| }).refine((data) => data.sql != null || data.sql_table != null || data.extends != null, { message: "sql, sql_table, or extends is required" }); | ||
| const viewIncludeItemSchema = z.union([z.string(), z.object({ | ||
| name: z.string(), | ||
| alias: z.string().optional(), | ||
| title: z.string().optional(), | ||
| description: z.string().optional(), | ||
| format: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| })]); | ||
| const viewCubeRefSchema = z.object({ | ||
| join_path: z.string(), | ||
| includes: z.union([z.literal("*"), z.array(viewIncludeItemSchema)]).optional(), | ||
| excludes: z.array(z.string()).optional(), | ||
| prefix: z.boolean().optional() | ||
| }); | ||
| const folderSchema = z.lazy(() => z.object({ | ||
| name: z.string(), | ||
| members: z.array(z.string()).optional(), | ||
| folders: z.array(folderSchema).optional() | ||
| })); | ||
| const viewMeasureSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| type: z.string(), | ||
| format: z.string().optional(), | ||
| description: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const viewDimensionSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| type: z.string(), | ||
| description: z.string().optional(), | ||
| meta: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
| const viewSegmentSchema = z.object({ | ||
| name: identifier, | ||
| sql: z.string(), | ||
| description: z.string().optional() | ||
| }); | ||
| const viewSchema = z.object({ | ||
| name: identifier, | ||
| description: z.string().optional(), | ||
| title: z.string().optional(), | ||
| public: z.boolean().optional(), | ||
| cubes: z.array(viewCubeRefSchema).optional(), | ||
| measures: z.array(viewMeasureSchema).optional(), | ||
| dimensions: z.array(viewDimensionSchema).optional(), | ||
| segments: z.array(viewSegmentSchema).optional(), | ||
| folders: z.array(folderSchema).optional() | ||
| }); | ||
| const fileSchema = z.object({ | ||
| cubes: z.array(cubeSchema).optional(), | ||
| views: z.array(viewSchema).optional() | ||
| }).refine((data) => data.cubes && data.cubes.length > 0 || data.views && data.views.length > 0, "File must contain at least one cube or view"); | ||
| function formatZodError(error, fileName, parsed) { | ||
| return error.issues.map((issue) => { | ||
| const pathParts = issue.path; | ||
| let entityContext = ""; | ||
| if (pathParts.length >= 2) { | ||
| const collection = pathParts[0]; | ||
| const index = pathParts[1]; | ||
| const entity = parsed?.[collection]?.[index]; | ||
| if (entity?.name) entityContext = ` (${entity.name})`; | ||
| } | ||
| const pathStr = pathParts.join("."); | ||
| const location = pathStr ? `${pathStr}${entityContext}` : ""; | ||
| if (issue.code === "invalid_value" && "values" in issue) return `${fileName}: ${location} — invalid value, expected one of: ${issue.values.join(", ")}`; | ||
| return `${fileName}: ${location ? `${location} — ` : ""}${issue.message}`; | ||
| }); | ||
| } | ||
| function validateFiles(files) { | ||
| const errors = []; | ||
| const cubes = []; | ||
| const views = []; | ||
| const allNames = /* @__PURE__ */ new Map(); | ||
| for (const file of files) { | ||
| let parsed; | ||
| try { | ||
| parsed = YAML.parse(file.content); | ||
| } catch (err) { | ||
| errors.push(`${file.fileName}: YAML parse error — ${err.message}`); | ||
| continue; | ||
| } | ||
| if (!parsed || typeof parsed !== "object") { | ||
| errors.push(`${file.fileName}: file is empty or not a YAML object`); | ||
| continue; | ||
| } | ||
| const result = fileSchema.safeParse(parsed); | ||
| if (!result.success) { | ||
| errors.push(...formatZodError(result.error, file.fileName, parsed)); | ||
| continue; | ||
| } | ||
| for (const cube of parsed.cubes ?? []) if (cube.name) { | ||
| const existing = allNames.get(cube.name); | ||
| if (existing) errors.push(`${file.fileName}: duplicate name '${cube.name}' (also defined in ${existing})`); | ||
| else { | ||
| allNames.set(cube.name, file.fileName); | ||
| cubes.push(cube.name); | ||
| } | ||
| } | ||
| for (const view of parsed.views ?? []) if (view.name) { | ||
| const existing = allNames.get(view.name); | ||
| if (existing) errors.push(`${file.fileName}: duplicate name '${view.name}' (also defined in ${existing})`); | ||
| else { | ||
| allNames.set(view.name, file.fileName); | ||
| views.push(view.name); | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| errors, | ||
| cubes, | ||
| views | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/validate.ts | ||
| function collectYamlFiles(dir, rootDir) { | ||
| if (!fs.existsSync(dir)) return []; | ||
| const results = []; | ||
| function walk(current) { | ||
| for (const entry of fs.readdirSync(current, { withFileTypes: true })) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (entry.isDirectory()) walk(fullPath); | ||
| else if (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml")) results.push({ | ||
| fileName: path.relative(rootDir, fullPath), | ||
| content: fs.readFileSync(fullPath, "utf-8") | ||
| }); | ||
| } | ||
| } | ||
| walk(dir); | ||
| return results; | ||
| } | ||
| function checkMissingDescriptions(files) { | ||
| const missing = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| const cubes = parsed.cubes || []; | ||
| for (const cube of cubes) { | ||
| if (!cube.name) continue; | ||
| if (!cube.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "cube", | ||
| name: cube.name | ||
| }); | ||
| const measures = cube.measures || []; | ||
| for (const measure of measures) if (measure.name && !measure.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "measure", | ||
| name: measure.name | ||
| }); | ||
| const dimensions = cube.dimensions || []; | ||
| for (const dimension of dimensions) if (dimension.name && !dimension.description) missing.push({ | ||
| parent: cube.name, | ||
| type: "dimension", | ||
| name: dimension.name | ||
| }); | ||
| } | ||
| const views = parsed.views || []; | ||
| for (const view of views) { | ||
| if (!view.name) continue; | ||
| if (!view.description) missing.push({ | ||
| parent: view.name, | ||
| type: "view", | ||
| name: view.name | ||
| }); | ||
| } | ||
| } catch {} | ||
| return missing; | ||
| } | ||
| function checkMissingDataSource(files) { | ||
| const missing = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const cube of parsed.cubes || []) if (cube.name && !cube.data_source) missing.push(cube.name); | ||
| } catch {} | ||
| return missing; | ||
| } | ||
| async function validate(projectPath) { | ||
| const paths = getProjectPaths(projectPath); | ||
| const files = [...collectYamlFiles(paths.cubes, projectPath), ...collectYamlFiles(paths.views, projectPath)]; | ||
| if (files.length === 0) return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [] | ||
| }; | ||
| const result = validateFiles(files); | ||
| if (result.errors.length > 0) return { | ||
| valid: false, | ||
| errors: result.errors, | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [] | ||
| }; | ||
| const missingDescriptions = checkMissingDescriptions(files); | ||
| const cubesMissingDataSource = checkMissingDataSource(files); | ||
| return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: result.cubes, | ||
| views: result.views, | ||
| missingDescriptions, | ||
| cubesMissingDataSource | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { validate }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
63
1.61%326002
-0.72%4287
-1.9%