|
| 1 | +/** |
| 2 | + * ObjectQL Plugin Analytics — Cube Registry |
| 3 | + * Copyright (c) 2026-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +import type { CubeMeta, MetadataRegistry, ObjectConfig } from '@objectql/types'; |
| 10 | +import type { CubeDefinition, CubeMeasure, CubeDimension } from './types'; |
| 11 | + |
| 12 | +/** |
| 13 | + * CubeRegistry |
| 14 | + * |
| 15 | + * Central registry for analytics cube definitions. |
| 16 | + * Supports both manifest-based registration and automatic |
| 17 | + * discovery from MetadataRegistry object definitions. |
| 18 | + */ |
| 19 | +export class CubeRegistry { |
| 20 | + private readonly cubes = new Map<string, CubeDefinition>(); |
| 21 | + |
| 22 | + /** Register a cube from a manifest definition. */ |
| 23 | + register(cube: CubeDefinition): void { |
| 24 | + this.cubes.set(cube.name, cube); |
| 25 | + } |
| 26 | + |
| 27 | + /** Register multiple cubes from manifest definitions. */ |
| 28 | + registerAll(cubes: readonly CubeDefinition[]): void { |
| 29 | + for (const cube of cubes) { |
| 30 | + this.register(cube); |
| 31 | + } |
| 32 | + } |
| 33 | + |
| 34 | + /** Look up a cube by name. Returns undefined if not found. */ |
| 35 | + get(name: string): CubeDefinition | undefined { |
| 36 | + return this.cubes.get(name); |
| 37 | + } |
| 38 | + |
| 39 | + /** List all registered cubes. */ |
| 40 | + list(): CubeDefinition[] { |
| 41 | + return Array.from(this.cubes.values()); |
| 42 | + } |
| 43 | + |
| 44 | + /** Convert a CubeDefinition to the spec-compliant CubeMeta format. */ |
| 45 | + toMeta(cube: CubeDefinition): CubeMeta { |
| 46 | + return { |
| 47 | + name: cube.name, |
| 48 | + title: cube.title, |
| 49 | + measures: cube.measures.map(m => ({ |
| 50 | + name: `${cube.name}.${m.name}`, |
| 51 | + type: m.type, |
| 52 | + title: m.title, |
| 53 | + })), |
| 54 | + dimensions: cube.dimensions.map(d => ({ |
| 55 | + name: `${cube.name}.${d.name}`, |
| 56 | + type: d.type, |
| 57 | + title: d.title, |
| 58 | + })), |
| 59 | + }; |
| 60 | + } |
| 61 | + |
| 62 | + /** Get CubeMeta for all cubes, optionally filtered by name. */ |
| 63 | + getMeta(cubeName?: string): CubeMeta[] { |
| 64 | + if (cubeName) { |
| 65 | + const cube = this.cubes.get(cubeName); |
| 66 | + return cube ? [this.toMeta(cube)] : []; |
| 67 | + } |
| 68 | + return this.list().map(c => this.toMeta(c)); |
| 69 | + } |
| 70 | + |
| 71 | + /** |
| 72 | + * Auto-discover cubes from MetadataRegistry. |
| 73 | + * |
| 74 | + * For each registered object, infer a cube with: |
| 75 | + * - A `count` measure (always available) |
| 76 | + * - `sum`/`avg`/`min`/`max` measures for every numeric field |
| 77 | + * - Dimensions for every non-numeric field (string, boolean, select) |
| 78 | + * - Time dimensions for date/datetime fields |
| 79 | + */ |
| 80 | + discoverFromMetadata(metadata: MetadataRegistry): void { |
| 81 | + const objects = this.listMetadataObjects(metadata); |
| 82 | + for (const obj of objects) { |
| 83 | + if (this.cubes.has(obj.name)) continue; // manifest takes precedence |
| 84 | + |
| 85 | + const measures: CubeMeasure[] = [ |
| 86 | + { name: 'count', type: 'count', field: '*' }, |
| 87 | + ]; |
| 88 | + const dimensions: CubeDimension[] = []; |
| 89 | + |
| 90 | + const fields = obj.fields || {}; |
| 91 | + for (const [fieldName, field] of Object.entries(fields)) { |
| 92 | + const fType = typeof field === 'object' && field !== null |
| 93 | + ? (field as unknown as Record<string, unknown>).type as string | undefined |
| 94 | + : undefined; |
| 95 | + |
| 96 | + if (this.isNumericType(fType)) { |
| 97 | + measures.push( |
| 98 | + { name: `${fieldName}_sum`, type: 'sum', field: fieldName, title: `Sum of ${fieldName}` }, |
| 99 | + { name: `${fieldName}_avg`, type: 'avg', field: fieldName, title: `Avg of ${fieldName}` }, |
| 100 | + { name: `${fieldName}_min`, type: 'min', field: fieldName, title: `Min of ${fieldName}` }, |
| 101 | + { name: `${fieldName}_max`, type: 'max', field: fieldName, title: `Max of ${fieldName}` }, |
| 102 | + ); |
| 103 | + } else if (this.isTimeType(fType)) { |
| 104 | + dimensions.push({ name: fieldName, type: 'time', field: fieldName }); |
| 105 | + } else { |
| 106 | + dimensions.push({ |
| 107 | + name: fieldName, |
| 108 | + type: this.mapFieldType(fType), |
| 109 | + field: fieldName, |
| 110 | + }); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + this.cubes.set(obj.name, { |
| 115 | + name: obj.name, |
| 116 | + title: obj.name, |
| 117 | + objectName: obj.name, |
| 118 | + measures, |
| 119 | + dimensions, |
| 120 | + }); |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + // ------------------------------------------------------------------- |
| 125 | + // Helpers |
| 126 | + // ------------------------------------------------------------------- |
| 127 | + |
| 128 | + private listMetadataObjects(metadata: MetadataRegistry): ObjectConfig[] { |
| 129 | + if (typeof (metadata as any).list === 'function') { |
| 130 | + return (metadata as any).list('object') as ObjectConfig[]; |
| 131 | + } |
| 132 | + if (typeof (metadata as any).getAll === 'function') { |
| 133 | + return (metadata as any).getAll('object') as ObjectConfig[]; |
| 134 | + } |
| 135 | + return []; |
| 136 | + } |
| 137 | + |
| 138 | + private isNumericType(type?: string): boolean { |
| 139 | + if (!type) return false; |
| 140 | + return ['number', 'currency', 'percent', 'integer', 'float', 'decimal'].includes(type); |
| 141 | + } |
| 142 | + |
| 143 | + private isTimeType(type?: string): boolean { |
| 144 | + if (!type) return false; |
| 145 | + return ['date', 'datetime', 'time', 'timestamp'].includes(type); |
| 146 | + } |
| 147 | + |
| 148 | + private mapFieldType(type?: string): 'string' | 'number' | 'boolean' { |
| 149 | + if (!type) return 'string'; |
| 150 | + if (type === 'boolean' || type === 'checkbox') return 'boolean'; |
| 151 | + return 'string'; |
| 152 | + } |
| 153 | +} |
0 commit comments