@bonnard/cli
Advanced tools
| import { n as getProjectPaths } from "./project-Dj085D_B.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 { r as post } from "./api-BZ9eHZdy.mjs"; | ||
| import { a as getLocalDatasource, c as resolveEnvVarsInCredentials } from "./local-ByvuW3eV.mjs"; | ||
| import pc from "picocolors"; | ||
| import "@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 (err) { | ||
| if (!options.silent) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error(pc.red(`Failed to push "${name}": ${message}`)); | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { pushDatasource as t }; |
| import "./api-BZ9eHZdy.mjs"; | ||
| import "./local-ByvuW3eV.mjs"; | ||
| import { t as pushDatasource } from "./push-GInMUUa2.mjs"; | ||
| export { pushDatasource }; |
| import { n as getProjectPaths } from "./project-Dj085D_B.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 checkViewMemberConflicts(parsedFiles, cubeMap) { | ||
| const errors = []; | ||
| for (const { fileName, parsed } of parsedFiles) for (const view of parsed.views ?? []) { | ||
| if (!view.name || !view.cubes) continue; | ||
| const seen = /* @__PURE__ */ new Map(); | ||
| for (const m of view.measures ?? []) if (m.name) seen.set(m.name, `${view.name} (direct)`); | ||
| for (const d of view.dimensions ?? []) if (d.name) seen.set(d.name, `${view.name} (direct)`); | ||
| for (const s of view.segments ?? []) if (s.name) seen.set(s.name, `${view.name} (direct)`); | ||
| for (const cubeRef of view.cubes) { | ||
| const joinPath = cubeRef.join_path; | ||
| if (!joinPath) continue; | ||
| const segments = joinPath.split("."); | ||
| const targetCubeName = segments[segments.length - 1]; | ||
| let memberNames = []; | ||
| if (cubeRef.includes === "*") { | ||
| const cube = cubeMap.get(targetCubeName); | ||
| if (!cube) continue; | ||
| memberNames = [ | ||
| ...cube.measures, | ||
| ...cube.dimensions, | ||
| ...cube.segments | ||
| ]; | ||
| } else if (Array.isArray(cubeRef.includes)) { | ||
| for (const item of cubeRef.includes) if (typeof item === "string") memberNames.push(item); | ||
| else if (item && typeof item === "object" && item.name) memberNames.push(item.alias || item.name); | ||
| } else continue; | ||
| if (Array.isArray(cubeRef.excludes)) { | ||
| const excludeSet = new Set(cubeRef.excludes); | ||
| memberNames = memberNames.filter((n) => !excludeSet.has(n)); | ||
| } | ||
| for (const rawName of memberNames) { | ||
| const finalName = cubeRef.prefix ? `${targetCubeName}_${rawName}` : rawName; | ||
| const existingSource = seen.get(finalName); | ||
| if (existingSource) errors.push(`${fileName}: view '${view.name}' — member '${finalName}' from '${joinPath}' conflicts with '${existingSource}'. Use prefix: true or an alias.`); | ||
| else seen.set(finalName, joinPath); | ||
| } | ||
| } | ||
| } | ||
| return errors; | ||
| } | ||
| function validateFiles(files) { | ||
| const errors = []; | ||
| const cubes = []; | ||
| const views = []; | ||
| const allNames = /* @__PURE__ */ new Map(); | ||
| const parsedFiles = []; | ||
| const cubeMap = /* @__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; | ||
| } | ||
| parsedFiles.push({ | ||
| fileName: file.fileName, | ||
| parsed | ||
| }); | ||
| 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); | ||
| cubeMap.set(cube.name, { | ||
| measures: (cube.measures ?? []).map((m) => m.name).filter(Boolean), | ||
| dimensions: (cube.dimensions ?? []).map((d) => d.name).filter(Boolean), | ||
| segments: (cube.segments ?? []).map((s) => s.name).filter(Boolean) | ||
| }); | ||
| } | ||
| } | ||
| 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); | ||
| } | ||
| } | ||
| } | ||
| if (errors.length === 0) errors.push(...checkViewMemberConflicts(parsedFiles, cubeMap)); | ||
| 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 checkSuspectPrimaryKeys(files) { | ||
| const suspects = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const cube of parsed.cubes || []) { | ||
| if (!cube.name) continue; | ||
| for (const dim of cube.dimensions || []) if (dim.primary_key && dim.type === "time") suspects.push({ | ||
| cube: cube.name, | ||
| dimension: dim.name, | ||
| type: dim.type | ||
| }); | ||
| } | ||
| } catch {} | ||
| return suspects; | ||
| } | ||
| 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; | ||
| } | ||
| function checkUnjoinedViewCubes(files) { | ||
| const results = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const view of parsed.views || []) { | ||
| if (!view.name || !view.cubes || view.cubes.length < 2) continue; | ||
| const roots = /* @__PURE__ */ new Set(); | ||
| for (const cubeRef of view.cubes) { | ||
| if (!cubeRef.join_path) continue; | ||
| const firstSegment = cubeRef.join_path.split(".")[0]; | ||
| roots.add(firstSegment); | ||
| } | ||
| if (roots.size > 1) results.push({ | ||
| view: view.name, | ||
| roots: [...roots] | ||
| }); | ||
| } | ||
| } catch {} | ||
| return results; | ||
| } | ||
| 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: [], | ||
| suspectPrimaryKeys: [], | ||
| unjoinedViewCubes: [] | ||
| }; | ||
| const result = validateFiles(files); | ||
| if (result.errors.length > 0) return { | ||
| valid: false, | ||
| errors: result.errors, | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [], | ||
| suspectPrimaryKeys: [], | ||
| unjoinedViewCubes: [] | ||
| }; | ||
| const missingDescriptions = checkMissingDescriptions(files); | ||
| const cubesMissingDataSource = checkMissingDataSource(files); | ||
| const suspectPrimaryKeys = checkSuspectPrimaryKeys(files); | ||
| const unjoinedViewCubes = checkUnjoinedViewCubes(files); | ||
| return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: result.cubes, | ||
| views: result.views, | ||
| missingDescriptions, | ||
| cubesMissingDataSource, | ||
| suspectPrimaryKeys, | ||
| unjoinedViewCubes | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { validate }; |
+1
-1
| { | ||
| "name": "@bonnard/cli", | ||
| "version": "0.3.10", | ||
| "version": "0.3.11", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "bin": { |
| import { n as getProjectPaths } from "./project-Dj085D_B.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 { r as post } from "./api-BZ9eHZdy.mjs"; | ||
| import { a as getLocalDatasource, c as resolveEnvVarsInCredentials } from "./local-ByvuW3eV.mjs"; | ||
| import pc from "picocolors"; | ||
| import "@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 (err) { | ||
| if (!options.silent) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error(pc.red(`Failed to push "${name}": ${message}`)); | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { pushDatasource }; |
| import { n as getProjectPaths } from "./project-Dj085D_B.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 checkViewMemberConflicts(parsedFiles, cubeMap) { | ||
| const errors = []; | ||
| for (const { fileName, parsed } of parsedFiles) for (const view of parsed.views ?? []) { | ||
| if (!view.name || !view.cubes) continue; | ||
| const seen = /* @__PURE__ */ new Map(); | ||
| for (const m of view.measures ?? []) if (m.name) seen.set(m.name, `${view.name} (direct)`); | ||
| for (const d of view.dimensions ?? []) if (d.name) seen.set(d.name, `${view.name} (direct)`); | ||
| for (const s of view.segments ?? []) if (s.name) seen.set(s.name, `${view.name} (direct)`); | ||
| for (const cubeRef of view.cubes) { | ||
| const joinPath = cubeRef.join_path; | ||
| if (!joinPath) continue; | ||
| const segments = joinPath.split("."); | ||
| const targetCubeName = segments[segments.length - 1]; | ||
| let memberNames = []; | ||
| if (cubeRef.includes === "*") { | ||
| const cube = cubeMap.get(targetCubeName); | ||
| if (!cube) continue; | ||
| memberNames = [ | ||
| ...cube.measures, | ||
| ...cube.dimensions, | ||
| ...cube.segments | ||
| ]; | ||
| } else if (Array.isArray(cubeRef.includes)) { | ||
| for (const item of cubeRef.includes) if (typeof item === "string") memberNames.push(item); | ||
| else if (item && typeof item === "object" && item.name) memberNames.push(item.alias || item.name); | ||
| } else continue; | ||
| if (Array.isArray(cubeRef.excludes)) { | ||
| const excludeSet = new Set(cubeRef.excludes); | ||
| memberNames = memberNames.filter((n) => !excludeSet.has(n)); | ||
| } | ||
| for (const rawName of memberNames) { | ||
| const finalName = cubeRef.prefix ? `${targetCubeName}_${rawName}` : rawName; | ||
| const existingSource = seen.get(finalName); | ||
| if (existingSource) errors.push(`${fileName}: view '${view.name}' — member '${finalName}' from '${joinPath}' conflicts with '${existingSource}'. Use prefix: true or an alias.`); | ||
| else seen.set(finalName, joinPath); | ||
| } | ||
| } | ||
| } | ||
| return errors; | ||
| } | ||
| function validateFiles(files) { | ||
| const errors = []; | ||
| const cubes = []; | ||
| const views = []; | ||
| const allNames = /* @__PURE__ */ new Map(); | ||
| const parsedFiles = []; | ||
| const cubeMap = /* @__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; | ||
| } | ||
| parsedFiles.push({ | ||
| fileName: file.fileName, | ||
| parsed | ||
| }); | ||
| 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); | ||
| cubeMap.set(cube.name, { | ||
| measures: (cube.measures ?? []).map((m) => m.name).filter(Boolean), | ||
| dimensions: (cube.dimensions ?? []).map((d) => d.name).filter(Boolean), | ||
| segments: (cube.segments ?? []).map((s) => s.name).filter(Boolean) | ||
| }); | ||
| } | ||
| } | ||
| 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); | ||
| } | ||
| } | ||
| } | ||
| if (errors.length === 0) errors.push(...checkViewMemberConflicts(parsedFiles, cubeMap)); | ||
| 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 checkSuspectPrimaryKeys(files) { | ||
| const suspects = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const cube of parsed.cubes || []) { | ||
| if (!cube.name) continue; | ||
| for (const dim of cube.dimensions || []) if (dim.primary_key && dim.type === "time") suspects.push({ | ||
| cube: cube.name, | ||
| dimension: dim.name, | ||
| type: dim.type | ||
| }); | ||
| } | ||
| } catch {} | ||
| return suspects; | ||
| } | ||
| 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; | ||
| } | ||
| function checkUnjoinedViewCubes(files) { | ||
| const results = []; | ||
| for (const file of files) try { | ||
| const parsed = YAML.parse(file.content); | ||
| if (!parsed) continue; | ||
| for (const view of parsed.views || []) { | ||
| if (!view.name || !view.cubes || view.cubes.length < 2) continue; | ||
| const roots = /* @__PURE__ */ new Set(); | ||
| for (const cubeRef of view.cubes) { | ||
| if (!cubeRef.join_path) continue; | ||
| const firstSegment = cubeRef.join_path.split(".")[0]; | ||
| roots.add(firstSegment); | ||
| } | ||
| if (roots.size > 1) results.push({ | ||
| view: view.name, | ||
| roots: [...roots] | ||
| }); | ||
| } | ||
| } catch {} | ||
| return results; | ||
| } | ||
| 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: [], | ||
| suspectPrimaryKeys: [], | ||
| unjoinedViewCubes: [] | ||
| }; | ||
| const result = validateFiles(files); | ||
| if (result.errors.length > 0) return { | ||
| valid: false, | ||
| errors: result.errors, | ||
| cubes: [], | ||
| views: [], | ||
| missingDescriptions: [], | ||
| cubesMissingDataSource: [], | ||
| suspectPrimaryKeys: [], | ||
| unjoinedViewCubes: [] | ||
| }; | ||
| const missingDescriptions = checkMissingDescriptions(files); | ||
| const cubesMissingDataSource = checkMissingDataSource(files); | ||
| const suspectPrimaryKeys = checkSuspectPrimaryKeys(files); | ||
| const unjoinedViewCubes = checkUnjoinedViewCubes(files); | ||
| return { | ||
| valid: true, | ||
| errors: [], | ||
| cubes: result.cubes, | ||
| views: result.views, | ||
| missingDescriptions, | ||
| cubesMissingDataSource, | ||
| suspectPrimaryKeys, | ||
| unjoinedViewCubes | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { validate }; |
Sorry, the diff of this file is too big to display
2377260
0.09%84
1.2%5479
1.03%