From 0014144f01033005f78b538bbe51f804fa7f0296 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:52:35 +0000 Subject: [PATCH 1/2] Initial plan From 6812ef3372e1ff6e792d36bf06ca6474ca15ace2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:07:32 +0000 Subject: [PATCH 2/2] feat: implement @objectql/plugin-analytics with multi-database analytical queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the IAnalyticsService contract from @objectstack/spec with: - CubeRegistry (manifest + auto-discovery from metadata) - SemanticCompiler (AnalyticsQuery + Cube → LogicalPlan) - NativeSQLStrategy (SQL push-down via Knex) - ObjectQLStrategy (driver.aggregate() delegation) - MemoryFallbackStrategy (in-memory aggregation for dev/test) - AnalyticsPlugin (kernel plugin, registers 'analytics' service) - generateSql() dry-run support - 44 integration tests covering all strategy branches Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/objectql/sessions/5b8e0997-4932-497c-8f39-0620b160ef0a --- CHANGELOG.md | 11 + ROADMAP.md | 1 + .../foundation/plugin-analytics/package.json | 27 + .../plugin-analytics/src/analytics-service.ts | 120 +++ .../plugin-analytics/src/cube-registry.ts | 153 ++++ .../foundation/plugin-analytics/src/index.ts | 43 + .../foundation/plugin-analytics/src/plugin.ts | 117 +++ .../plugin-analytics/src/semantic-compiler.ts | 163 ++++ .../plugin-analytics/src/strategy-memory.ts | 223 +++++ .../plugin-analytics/src/strategy-objectql.ts | 149 ++++ .../plugin-analytics/src/strategy-sql.ts | 247 ++++++ .../foundation/plugin-analytics/src/types.ts | 119 +++ .../plugin-analytics/test/analytics.test.ts | 763 ++++++++++++++++++ .../foundation/plugin-analytics/tsconfig.json | 12 + pnpm-lock.yaml | 13 + vitest.config.ts | 1 + 16 files changed, 2162 insertions(+) create mode 100644 packages/foundation/plugin-analytics/package.json create mode 100644 packages/foundation/plugin-analytics/src/analytics-service.ts create mode 100644 packages/foundation/plugin-analytics/src/cube-registry.ts create mode 100644 packages/foundation/plugin-analytics/src/index.ts create mode 100644 packages/foundation/plugin-analytics/src/plugin.ts create mode 100644 packages/foundation/plugin-analytics/src/semantic-compiler.ts create mode 100644 packages/foundation/plugin-analytics/src/strategy-memory.ts create mode 100644 packages/foundation/plugin-analytics/src/strategy-objectql.ts create mode 100644 packages/foundation/plugin-analytics/src/strategy-sql.ts create mode 100644 packages/foundation/plugin-analytics/src/types.ts create mode 100644 packages/foundation/plugin-analytics/test/analytics.test.ts create mode 100644 packages/foundation/plugin-analytics/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb5ea3e..a961ebaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`@objectql/plugin-analytics`** — new analytics/BI plugin providing multi-database analytical query support. Implements the `IAnalyticsService` contract from `@objectstack/spec` with strategy-based driver dispatch: + - `NativeSQLStrategy` — pushes analytics to SQL databases via Knex (Postgres, SQLite, MySQL). + - `ObjectQLStrategy` — delegates to driver's native `aggregate()` method (MongoDB, etc.). + - `MemoryFallbackStrategy` — in-memory aggregation for dev/test environments. + - `CubeRegistry` — supports manifest-based and automatic model/metadata-inferred cube definitions. + - `SemanticCompiler` — compiles `AnalyticsQuery` + `CubeDefinition` into driver-agnostic `LogicalPlan`. + - `generateSql()` — SQL dry-run/explanation support for query debugging. + - `AnalyticsPlugin` — kernel plugin registering `'analytics'` service for REST API discovery. + ### Fixed - **`apps/demo/scripts/patch-symlinks.cjs`** — enhanced to automatically resolve and copy ALL transitive dependencies before dereferencing symlinks. Previously, only direct dependencies listed in `apps/demo/package.json` were available after symlink dereferencing, causing `ERR_MODULE_NOT_FOUND` for transitive deps like `@objectstack/rest`, `zod`, `pino`, `better-auth`, etc. The script now walks each package's pnpm virtual store context (`.pnpm/@/node_modules/`) and copies any missing sibling dependency into the top-level `node_modules/`, repeating until the full transitive closure is present. diff --git a/ROADMAP.md b/ROADMAP.md index fca31703..112a705d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -980,6 +980,7 @@ Standardize third-party plugin distribution. | `packages/foundation/plugin-workflow` | `@objectql/plugin-workflow` | Universal | State machine executor with guards, actions, compound states. | | `packages/foundation/plugin-multitenancy` | `@objectql/plugin-multitenancy` | Universal | Tenant isolation via hook-based filter rewriting. | | `packages/foundation/plugin-sync` | `@objectql/plugin-sync` | Universal | Offline-first sync engine with mutation logging and conflict resolution. | +| `packages/foundation/plugin-analytics` | `@objectql/plugin-analytics` | Universal | Analytics/BI plugin with multi-database strategy dispatch (SQL, Mongo, Memory). | | `packages/foundation/edge-adapter` | `@objectql/edge-adapter` | Universal | Edge runtime detection and capability validation. | ### Driver Layer diff --git a/packages/foundation/plugin-analytics/package.json b/packages/foundation/plugin-analytics/package.json new file mode 100644 index 00000000..0543320a --- /dev/null +++ b/packages/foundation/plugin-analytics/package.json @@ -0,0 +1,27 @@ +{ + "name": "@objectql/plugin-analytics", + "version": "4.2.2", + "description": "Analytics and BI plugin for ObjectQL — multi-database analytical queries with strategy-based driver dispatch", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@objectql/types": "workspace:*", + "@objectstack/spec": "^3.2.8" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/foundation/plugin-analytics/src/analytics-service.ts b/packages/foundation/plugin-analytics/src/analytics-service.ts new file mode 100644 index 00000000..124466d4 --- /dev/null +++ b/packages/foundation/plugin-analytics/src/analytics-service.ts @@ -0,0 +1,120 @@ +/** + * ObjectQL Plugin Analytics — AnalyticsService + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { IAnalyticsService, AnalyticsQuery, AnalyticsResult, CubeMeta } from '@objectql/types'; +import { ObjectQLError } from '@objectql/types'; +import type { AnalyticsStrategy } from './types'; +import { CubeRegistry } from './cube-registry'; +import { SemanticCompiler } from './semantic-compiler'; +import { NativeSQLStrategy } from './strategy-sql'; +import { ObjectQLStrategy } from './strategy-objectql'; +import { MemoryFallbackStrategy } from './strategy-memory'; + +/** + * AnalyticsService + * + * Implements IAnalyticsService from @objectstack/spec. + * Dispatches analytics queries through a strategy pipeline: + * + * 1. SemanticCompiler → LogicalPlan + * 2. Strategy selection (based on driver capabilities) + * 3. Strategy.execute → AnalyticsResult + * + * Strategy selection order: + * a. NativeSQLStrategy — if driver exposes a `knex` instance (SQL push-down) + * b. ObjectQLStrategy — if driver supports `aggregate()` + `queryAggregations` + * c. MemoryFallbackStrategy — fallback for dev/test (fetch all → JS aggregation) + */ +export class AnalyticsService implements IAnalyticsService { + private readonly compiler: SemanticCompiler; + private readonly sqlStrategy = new NativeSQLStrategy(); + private readonly objectqlStrategy = new ObjectQLStrategy(); + private readonly memoryStrategy = new MemoryFallbackStrategy(); + + constructor( + readonly registry: CubeRegistry, + private readonly datasources: Record, + ) { + this.compiler = new SemanticCompiler(registry); + } + + // ------------------------------------------------------------------- + // IAnalyticsService implementation + // ------------------------------------------------------------------- + + async query(query: AnalyticsQuery): Promise { + const plan = this.compiler.compile(query); + const driver = this.resolveDriver(plan.datasource); + const strategy = this.selectStrategy(driver); + return strategy.execute(plan, driver); + } + + async getMeta(cubeName?: string): Promise { + return this.registry.getMeta(cubeName); + } + + async generateSql(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }> { + const plan = this.compiler.compile(query); + const driver = this.resolveDriver(plan.datasource); + + // Prefer SQL strategy's generateSql with live knex for accurate dialect + if (this.isSqlDriver(driver)) { + return this.sqlStrategy.buildQuery(plan, this.getKnex(driver)); + } + + // Fallback to plain SQL generation + return this.sqlStrategy.generateSql(plan); + } + + // ------------------------------------------------------------------- + // Strategy selection + // ------------------------------------------------------------------- + + selectStrategy(driver: unknown): AnalyticsStrategy { + if (this.isSqlDriver(driver)) { + return this.sqlStrategy; + } + if (this.supportsAggregation(driver)) { + return this.objectqlStrategy; + } + return this.memoryStrategy; + } + + // ------------------------------------------------------------------- + // Driver helpers + // ------------------------------------------------------------------- + + private resolveDriver(datasource: string): unknown { + const driver = this.datasources[datasource]; + if (!driver) { + throw new ObjectQLError({ + code: 'ANALYTICS_DATASOURCE_NOT_FOUND', + message: `Datasource '${datasource}' not found. Available: ${Object.keys(this.datasources).join(', ') || '(none)'}`, + }); + } + return driver; + } + + private isSqlDriver(driver: unknown): boolean { + const d = driver as any; + return !!(d.knex || (typeof d.getKnex === 'function')); + } + + private supportsAggregation(driver: unknown): boolean { + const d = driver as any; + return ( + typeof d.aggregate === 'function' && + d.supports?.queryAggregations === true + ); + } + + private getKnex(driver: unknown): any { + const d = driver as any; + return d.knex || d.getKnex?.(); + } +} diff --git a/packages/foundation/plugin-analytics/src/cube-registry.ts b/packages/foundation/plugin-analytics/src/cube-registry.ts new file mode 100644 index 00000000..27732e64 --- /dev/null +++ b/packages/foundation/plugin-analytics/src/cube-registry.ts @@ -0,0 +1,153 @@ +/** + * ObjectQL Plugin Analytics — Cube Registry + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { CubeMeta, MetadataRegistry, ObjectConfig } from '@objectql/types'; +import type { CubeDefinition, CubeMeasure, CubeDimension } from './types'; + +/** + * CubeRegistry + * + * Central registry for analytics cube definitions. + * Supports both manifest-based registration and automatic + * discovery from MetadataRegistry object definitions. + */ +export class CubeRegistry { + private readonly cubes = new Map(); + + /** Register a cube from a manifest definition. */ + register(cube: CubeDefinition): void { + this.cubes.set(cube.name, cube); + } + + /** Register multiple cubes from manifest definitions. */ + registerAll(cubes: readonly CubeDefinition[]): void { + for (const cube of cubes) { + this.register(cube); + } + } + + /** Look up a cube by name. Returns undefined if not found. */ + get(name: string): CubeDefinition | undefined { + return this.cubes.get(name); + } + + /** List all registered cubes. */ + list(): CubeDefinition[] { + return Array.from(this.cubes.values()); + } + + /** Convert a CubeDefinition to the spec-compliant CubeMeta format. */ + toMeta(cube: CubeDefinition): CubeMeta { + return { + name: cube.name, + title: cube.title, + measures: cube.measures.map(m => ({ + name: `${cube.name}.${m.name}`, + type: m.type, + title: m.title, + })), + dimensions: cube.dimensions.map(d => ({ + name: `${cube.name}.${d.name}`, + type: d.type, + title: d.title, + })), + }; + } + + /** Get CubeMeta for all cubes, optionally filtered by name. */ + getMeta(cubeName?: string): CubeMeta[] { + if (cubeName) { + const cube = this.cubes.get(cubeName); + return cube ? [this.toMeta(cube)] : []; + } + return this.list().map(c => this.toMeta(c)); + } + + /** + * Auto-discover cubes from MetadataRegistry. + * + * For each registered object, infer a cube with: + * - A `count` measure (always available) + * - `sum`/`avg`/`min`/`max` measures for every numeric field + * - Dimensions for every non-numeric field (string, boolean, select) + * - Time dimensions for date/datetime fields + */ + discoverFromMetadata(metadata: MetadataRegistry): void { + const objects = this.listMetadataObjects(metadata); + for (const obj of objects) { + if (this.cubes.has(obj.name)) continue; // manifest takes precedence + + const measures: CubeMeasure[] = [ + { name: 'count', type: 'count', field: '*' }, + ]; + const dimensions: CubeDimension[] = []; + + const fields = obj.fields || {}; + for (const [fieldName, field] of Object.entries(fields)) { + const fType = typeof field === 'object' && field !== null + ? (field as unknown as Record).type as string | undefined + : undefined; + + if (this.isNumericType(fType)) { + measures.push( + { name: `${fieldName}_sum`, type: 'sum', field: fieldName, title: `Sum of ${fieldName}` }, + { name: `${fieldName}_avg`, type: 'avg', field: fieldName, title: `Avg of ${fieldName}` }, + { name: `${fieldName}_min`, type: 'min', field: fieldName, title: `Min of ${fieldName}` }, + { name: `${fieldName}_max`, type: 'max', field: fieldName, title: `Max of ${fieldName}` }, + ); + } else if (this.isTimeType(fType)) { + dimensions.push({ name: fieldName, type: 'time', field: fieldName }); + } else { + dimensions.push({ + name: fieldName, + type: this.mapFieldType(fType), + field: fieldName, + }); + } + } + + this.cubes.set(obj.name, { + name: obj.name, + title: obj.name, + objectName: obj.name, + measures, + dimensions, + }); + } + } + + // ------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------- + + private listMetadataObjects(metadata: MetadataRegistry): ObjectConfig[] { + if (typeof (metadata as any).list === 'function') { + return (metadata as any).list('object') as ObjectConfig[]; + } + if (typeof (metadata as any).getAll === 'function') { + return (metadata as any).getAll('object') as ObjectConfig[]; + } + return []; + } + + private isNumericType(type?: string): boolean { + if (!type) return false; + return ['number', 'currency', 'percent', 'integer', 'float', 'decimal'].includes(type); + } + + private isTimeType(type?: string): boolean { + if (!type) return false; + return ['date', 'datetime', 'time', 'timestamp'].includes(type); + } + + private mapFieldType(type?: string): 'string' | 'number' | 'boolean' { + if (!type) return 'string'; + if (type === 'boolean' || type === 'checkbox') return 'boolean'; + return 'string'; + } +} diff --git a/packages/foundation/plugin-analytics/src/index.ts b/packages/foundation/plugin-analytics/src/index.ts new file mode 100644 index 00000000..8b017945 --- /dev/null +++ b/packages/foundation/plugin-analytics/src/index.ts @@ -0,0 +1,43 @@ +/** + * ObjectQL Plugin Analytics + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Multi-database analytical query plugin for ObjectQL. + * Provides strategy-based driver dispatch for SQL, MongoDB, and in-memory engines. + * + * @example + * ```typescript + * import { AnalyticsPlugin } from '@objectql/plugin-analytics'; + * + * const kernel = new ObjectStackKernel([ + * new AnalyticsPlugin({ autoDiscover: true }), + * ]); + * ``` + */ + +export { AnalyticsPlugin } from './plugin'; +export { AnalyticsService } from './analytics-service'; +export { CubeRegistry } from './cube-registry'; +export { SemanticCompiler } from './semantic-compiler'; +export { NativeSQLStrategy } from './strategy-sql'; +export { ObjectQLStrategy } from './strategy-objectql'; +export { MemoryFallbackStrategy } from './strategy-memory'; + +export type { + CubeDefinition, + CubeMeasure, + CubeDimension, + LogicalPlan, + LogicalPlanMeasure, + LogicalPlanDimension, + LogicalPlanFilter, + LogicalPlanTimeDimension, + AnalyticsStrategy, + AnalyticsPluginConfig, +} from './types'; + +// Re-export spec types for consumer convenience +export type { AnalyticsQuery, AnalyticsResult, CubeMeta } from './types'; diff --git a/packages/foundation/plugin-analytics/src/plugin.ts b/packages/foundation/plugin-analytics/src/plugin.ts new file mode 100644 index 00000000..279e1311 --- /dev/null +++ b/packages/foundation/plugin-analytics/src/plugin.ts @@ -0,0 +1,117 @@ +/** + * ObjectQL Plugin Analytics — Plugin Entry Point + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { RuntimePlugin, RuntimeContext, Logger } from '@objectql/types'; +import { ConsoleLogger } from '@objectql/types'; +import type { AnalyticsPluginConfig } from './types'; +import { CubeRegistry } from './cube-registry'; +import { AnalyticsService } from './analytics-service'; + +/** + * AnalyticsPlugin + * + * Kernel plugin that installs the AnalyticsService. + * Registers the 'analytics' service on the kernel for REST/API discovery. + * + * @example + * ```typescript + * import { AnalyticsPlugin } from '@objectql/plugin-analytics'; + * + * const kernel = new ObjectStackKernel([ + * new AnalyticsPlugin({ + * cubes: [myCubeDefinition], + * autoDiscover: true, + * }), + * ]); + * ``` + */ +export class AnalyticsPlugin implements RuntimePlugin { + name = '@objectql/plugin-analytics'; + version = '4.2.2'; + private logger: Logger; + private service: AnalyticsService | null = null; + + constructor(private config: AnalyticsPluginConfig = {}) { + this.logger = new ConsoleLogger({ name: this.name, level: 'info' }); + } + + async install(ctx: RuntimeContext): Promise { + this.logger.info('Installing analytics plugin...'); + + const kernel = ctx.engine as any; + + // 1. Resolve datasources + let datasources = this.config.datasources as Record | undefined; + if (!datasources) { + const drivers = kernel.getAllDrivers?.(); + if (drivers && drivers.length > 0) { + datasources = {}; + drivers.forEach((driver: any, index: number) => { + const driverName = driver.name || (index === 0 ? 'default' : `driver_${index + 1}`); + datasources![driverName] = driver; + }); + } + } + + if (!datasources || Object.keys(datasources).length === 0) { + this.logger.warn('No datasources available. AnalyticsService will not be registered.'); + return; + } + + // 2. Build CubeRegistry + const registry = new CubeRegistry(); + + // Register manifest cubes + if (this.config.cubes && this.config.cubes.length > 0) { + registry.registerAll(this.config.cubes); + this.logger.debug(`Registered ${this.config.cubes.length} manifest cube(s)`); + } + + // Auto-discover cubes from metadata + if (this.config.autoDiscover !== false && kernel.metadata) { + registry.discoverFromMetadata(kernel.metadata); + this.logger.debug(`Auto-discovered cubes from metadata (total: ${registry.list().length})`); + } + + // 3. Create AnalyticsService + this.service = new AnalyticsService(registry, datasources); + kernel.analyticsService = this.service; + this.logger.debug('AnalyticsService registered on kernel'); + + // 4. Register as named service for kernel discovery + if (typeof (ctx as any).registerService === 'function') { + (ctx as any).registerService('analytics', this.service); + this.logger.debug("Registered 'analytics' service alias"); + } + + this.logger.info('Analytics plugin installed successfully'); + } + + async onStart(_ctx: RuntimeContext): Promise { + // Analytics service is stateless — nothing to start + } + + async onStop(_ctx: RuntimeContext): Promise { + this.service = null; + } + + // --- Adapter for @objectstack/core compatibility --- + init = async (pluginCtx: any): Promise => { + const actualKernel = typeof pluginCtx.getKernel === 'function' + ? pluginCtx.getKernel() + : pluginCtx; + const ctx: any = { + engine: actualKernel, + getKernel: () => actualKernel, + registerService: typeof pluginCtx.registerService === 'function' + ? pluginCtx.registerService.bind(pluginCtx) + : undefined, + }; + await this.install(ctx); + }; +} diff --git a/packages/foundation/plugin-analytics/src/semantic-compiler.ts b/packages/foundation/plugin-analytics/src/semantic-compiler.ts new file mode 100644 index 00000000..a35df782 --- /dev/null +++ b/packages/foundation/plugin-analytics/src/semantic-compiler.ts @@ -0,0 +1,163 @@ +/** + * ObjectQL Plugin Analytics — Semantic Compiler + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { ObjectQLError } from '@objectql/types'; +import type { AnalyticsQuery } from '@objectql/types'; +import type { CubeDefinition, LogicalPlan, LogicalPlanMeasure, LogicalPlanDimension, LogicalPlanFilter, LogicalPlanTimeDimension } from './types'; +import type { CubeRegistry } from './cube-registry'; + +/** + * SemanticCompiler + * + * Compiles an AnalyticsQuery + CubeDefinition into a driver-agnostic LogicalPlan. + * This is the "front-end" of the analytics compilation pipeline. + * + * AnalyticsQuery + Cube ──► SemanticCompiler ──► LogicalPlan + * │ + * ┌─────────────┼─────────────┐ + * ▼ ▼ ▼ + * NativeSQLStrategy ObjectQL MemoryFallback + */ +export class SemanticCompiler { + constructor(private readonly registry: CubeRegistry) {} + + /** + * Compile an AnalyticsQuery into a LogicalPlan. + * @throws ObjectQLError if cube is not found or measures/dimensions are invalid. + */ + compile(query: AnalyticsQuery): LogicalPlan { + const cubeName = this.resolveCubeName(query); + const cube = this.registry.get(cubeName); + if (!cube) { + throw new ObjectQLError({ + code: 'ANALYTICS_CUBE_NOT_FOUND', + message: `Cube '${cubeName}' is not registered. Available cubes: ${this.registry.list().map(c => c.name).join(', ') || '(none)'}`, + }); + } + + const measures = this.compileMeasures(query.measures, cube); + const dimensions = this.compileDimensions(query.dimensions, cube); + const filters = this.compileFilters(query.filters, cube); + const timeDimensions = this.compileTimeDimensions(query.timeDimensions, cube); + + return { + objectName: cube.objectName, + datasource: cube.datasource || 'default', + measures, + dimensions, + filters, + timeDimensions, + order: query.order, + limit: query.limit, + offset: query.offset, + timezone: query.timezone, + }; + } + + // ------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------- + + private resolveCubeName(query: AnalyticsQuery): string { + if (query.cube) return query.cube; + + // Infer cube from the first measure reference (e.g. 'orders.count' → 'orders') + if (query.measures.length > 0) { + const dotIdx = query.measures[0].indexOf('.'); + if (dotIdx > 0) return query.measures[0].substring(0, dotIdx); + } + + throw new ObjectQLError({ + code: 'ANALYTICS_MISSING_CUBE', + message: 'AnalyticsQuery must specify a cube name either explicitly or via dotted measure references.', + }); + } + + private compileMeasures(measureRefs: string[], cube: CubeDefinition): LogicalPlanMeasure[] { + return measureRefs.map(ref => { + const measureName = this.stripCubePrefix(ref, cube.name); + const def = cube.measures.find(m => m.name === measureName); + if (!def) { + throw new ObjectQLError({ + code: 'ANALYTICS_INVALID_MEASURE', + message: `Measure '${ref}' not found in cube '${cube.name}'. Available: ${cube.measures.map(m => m.name).join(', ')}`, + }); + } + return { + cube: cube.name, + measure: def.name, + aggregation: def.type, + field: def.field || def.name, + alias: `${cube.name}__${def.name}`, + }; + }); + } + + private compileDimensions(dimRefs: string[] | undefined, cube: CubeDefinition): LogicalPlanDimension[] { + if (!dimRefs || dimRefs.length === 0) return []; + return dimRefs.map(ref => { + const dimName = this.stripCubePrefix(ref, cube.name); + const def = cube.dimensions.find(d => d.name === dimName); + if (!def) { + throw new ObjectQLError({ + code: 'ANALYTICS_INVALID_DIMENSION', + message: `Dimension '${ref}' not found in cube '${cube.name}'. Available: ${cube.dimensions.map(d => d.name).join(', ')}`, + }); + } + return { + cube: cube.name, + dimension: def.name, + field: def.field || def.name, + alias: `${cube.name}__${def.name}`, + }; + }); + } + + private compileFilters( + filters: AnalyticsQuery['filters'], + cube: CubeDefinition, + ): LogicalPlanFilter[] { + if (!filters || filters.length === 0) return []; + return filters.map(f => { + const memberName = this.stripCubePrefix(f.member, cube.name); + // Resolve to field name from dimension or measure + const dim = cube.dimensions.find(d => d.name === memberName); + const meas = cube.measures.find(m => m.name === memberName); + const field = dim?.field || dim?.name || meas?.field || meas?.name || memberName; + return { + field, + operator: f.operator, + values: f.values, + }; + }); + } + + private compileTimeDimensions( + timeDims: AnalyticsQuery['timeDimensions'], + cube: CubeDefinition, + ): LogicalPlanTimeDimension[] { + if (!timeDims || timeDims.length === 0) return []; + return timeDims.map(td => { + const dimName = this.stripCubePrefix(td.dimension, cube.name); + const def = cube.dimensions.find(d => d.name === dimName); + const field = def?.field || def?.name || dimName; + return { + field, + granularity: td.granularity, + dateRange: td.dateRange, + alias: `${cube.name}__${dimName}`, + }; + }); + } + + /** Strip 'cubeName.' prefix from a reference if present. */ + private stripCubePrefix(ref: string, cubeName: string): string { + const prefix = `${cubeName}.`; + return ref.startsWith(prefix) ? ref.substring(prefix.length) : ref; + } +} diff --git a/packages/foundation/plugin-analytics/src/strategy-memory.ts b/packages/foundation/plugin-analytics/src/strategy-memory.ts new file mode 100644 index 00000000..ec9935f1 --- /dev/null +++ b/packages/foundation/plugin-analytics/src/strategy-memory.ts @@ -0,0 +1,223 @@ +/** + * ObjectQL Plugin Analytics — MemoryFallbackStrategy + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AnalyticsResult } from '@objectql/types'; +import type { AnalyticsStrategy, LogicalPlan } from './types'; + +/** + * MemoryFallbackStrategy + * + * Executes analytics queries entirely in-memory by fetching all rows from + * the driver via `find()` and performing aggregation in JavaScript. + * + * This strategy is intended for dev/test environments and small datasets. + * It does NOT push computation down to the database. + */ +export class MemoryFallbackStrategy implements AnalyticsStrategy { + readonly name = 'memory-fallback'; + + async execute(plan: LogicalPlan, driver: unknown): Promise { + const d = driver as any; + if (typeof d.find !== 'function') { + throw new Error('MemoryFallbackStrategy requires a driver with a find() method.'); + } + + // 1. Fetch all matching records + const findQuery: Record = {}; + if (plan.filters.length > 0) { + findQuery.where = this.buildWhere(plan); + } + const allRows: Record[] = await d.find(plan.objectName, findQuery); + + // 2. Group rows by dimensions + const groups = this.groupRows(allRows, plan); + + // 3. Compute aggregates per group + const resultRows = this.computeAggregates(groups, plan); + + // 4. Sort + const sorted = this.sortRows(resultRows, plan); + + // 5. Paginate + const paginated = this.paginate(sorted, plan); + + return { + rows: paginated, + fields: this.buildFields(plan), + }; + } + + // ------------------------------------------------------------------- + // Grouping + // ------------------------------------------------------------------- + + private groupRows( + rows: Record[], + plan: LogicalPlan, + ): Map[]> { + const groups = new Map[]>(); + + if (plan.dimensions.length === 0) { + // Single group — all rows + groups.set('__all__', rows); + return groups; + } + + for (const row of rows) { + const key = plan.dimensions.map(d => String(row[d.field] ?? '')).join('|||'); + const group = groups.get(key); + if (group) { + group.push(row); + } else { + groups.set(key, [row]); + } + } + + return groups; + } + + // ------------------------------------------------------------------- + // Aggregation + // ------------------------------------------------------------------- + + private computeAggregates( + groups: Map[]>, + plan: LogicalPlan, + ): Record[] { + const results: Record[] = []; + + for (const [, rows] of groups) { + const result: Record = {}; + + // Dimension values from first row in group + for (const dim of plan.dimensions) { + result[dim.alias] = rows.length > 0 ? rows[0][dim.field] : null; + } + + // Compute each measure + for (const m of plan.measures) { + result[m.alias] = this.computeMeasure(m.aggregation, m.field, rows); + } + + results.push(result); + } + + return results; + } + + private computeMeasure( + aggregation: string, + field: string, + rows: Record[], + ): number { + if (aggregation === 'count') { + return field === '*' ? rows.length : rows.filter(r => r[field] != null).length; + } + + const values = rows + .map(r => r[field]) + .filter((v): v is number => typeof v === 'number'); + + if (values.length === 0) return 0; + + switch (aggregation) { + case 'sum': + return values.reduce((a, b) => a + b, 0); + case 'avg': + return values.reduce((a, b) => a + b, 0) / values.length; + case 'min': + return Math.min(...values); + case 'max': + return Math.max(...values); + case 'countDistinct': + return new Set(values).size; + default: + return rows.length; + } + } + + // ------------------------------------------------------------------- + // Filtering / Sorting / Pagination + // ------------------------------------------------------------------- + + private buildWhere(plan: LogicalPlan): Record { + const where: Record = {}; + for (const f of plan.filters) { + const values = f.values || []; + switch (f.operator) { + case 'equals': + where[f.field] = values[0]; + break; + case 'notEquals': + where[f.field] = { $ne: values[0] }; + break; + case 'gt': + where[f.field] = { $gt: values[0] }; + break; + case 'gte': + where[f.field] = { $gte: values[0] }; + break; + case 'lt': + where[f.field] = { $lt: values[0] }; + break; + case 'lte': + where[f.field] = { $lte: values[0] }; + break; + case 'in': + where[f.field] = { $in: values }; + break; + default: + if (values.length === 1) { + where[f.field] = values[0]; + } + } + } + return where; + } + + private sortRows( + rows: Record[], + plan: LogicalPlan, + ): Record[] { + if (!plan.order || Object.keys(plan.order).length === 0) return rows; + + const entries = Object.entries(plan.order); + return [...rows].sort((a, b) => { + for (const [key, dir] of entries) { + const va = a[key]; + const vb = b[key]; + if (va === vb) continue; + if (va == null) return dir === 'asc' ? -1 : 1; + if (vb == null) return dir === 'asc' ? 1 : -1; + const cmp = va < vb ? -1 : 1; + return dir === 'asc' ? cmp : -cmp; + } + return 0; + }); + } + + private paginate( + rows: Record[], + plan: LogicalPlan, + ): Record[] { + const start = plan.offset ?? 0; + const end = plan.limit != null ? start + plan.limit : rows.length; + return rows.slice(start, end); + } + + private buildFields(plan: LogicalPlan): Array<{ name: string; type: string }> { + const fields: Array<{ name: string; type: string }> = []; + for (const dim of plan.dimensions) { + fields.push({ name: dim.alias, type: 'string' }); + } + for (const m of plan.measures) { + fields.push({ name: m.alias, type: 'number' }); + } + return fields; + } +} diff --git a/packages/foundation/plugin-analytics/src/strategy-objectql.ts b/packages/foundation/plugin-analytics/src/strategy-objectql.ts new file mode 100644 index 00000000..83865621 --- /dev/null +++ b/packages/foundation/plugin-analytics/src/strategy-objectql.ts @@ -0,0 +1,149 @@ +/** + * ObjectQL Plugin Analytics — ObjectQLStrategy + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AnalyticsResult } from '@objectql/types'; +import type { AnalyticsStrategy, LogicalPlan } from './types'; + +/** + * ObjectQLStrategy + * + * Delegates analytics execution to the driver's generic aggregate() method. + * This strategy is used when the driver reports `queryAggregations: true` + * but is not a SQL driver (e.g. MongoDB with native $group pipeline support). + */ +export class ObjectQLStrategy implements AnalyticsStrategy { + readonly name = 'objectql-aggregate'; + + async execute(plan: LogicalPlan, driver: unknown): Promise { + const d = driver as any; + + if (typeof d.aggregate !== 'function') { + throw new Error( + 'ObjectQLStrategy requires a driver that implements aggregate(). ' + + 'Use MemoryFallbackStrategy for drivers without aggregation support.', + ); + } + + // Build a query object compatible with the driver's aggregate interface + const query = this.buildAggregateQuery(plan); + const rows: Record[] = await d.aggregate(plan.objectName, query); + + return { + rows: this.renameToAliases(rows, plan), + fields: this.buildFields(plan), + }; + } + + // ------------------------------------------------------------------- + // Query construction + // ------------------------------------------------------------------- + + private buildAggregateQuery(plan: LogicalPlan): Record { + const query: Record = {}; + + // Aggregations + query.aggregations = plan.measures.map(m => ({ + function: m.aggregation === 'countDistinct' ? 'count' : m.aggregation, + field: m.field === '*' ? '_id' : m.field, + alias: m.alias, + })); + + // GroupBy + if (plan.dimensions.length > 0) { + query.groupBy = plan.dimensions.map(d => d.field); + } + + // Where + if (plan.filters.length > 0) { + query.where = this.buildWhere(plan); + } + + // Limit / offset + if (plan.limit != null) query.limit = plan.limit; + if (plan.offset != null) query.offset = plan.offset; + + return query; + } + + private buildWhere(plan: LogicalPlan): Record { + const where: Record = {}; + for (const f of plan.filters) { + const values = f.values || []; + switch (f.operator) { + case 'equals': + where[f.field] = values[0]; + break; + case 'notEquals': + where[f.field] = { $ne: values[0] }; + break; + case 'gt': + where[f.field] = { $gt: values[0] }; + break; + case 'gte': + where[f.field] = { $gte: values[0] }; + break; + case 'lt': + where[f.field] = { $lt: values[0] }; + break; + case 'lte': + where[f.field] = { $lte: values[0] }; + break; + case 'in': + where[f.field] = { $in: values }; + break; + case 'notIn': + where[f.field] = { $nin: values }; + break; + case 'contains': + where[f.field] = { $regex: values[0] }; + break; + default: + if (values.length === 1) { + where[f.field] = values[0]; + } + } + } + return where; + } + + private renameToAliases( + rows: Record[], + plan: LogicalPlan, + ): Record[] { + // Build rename map: field → alias + const renameMap = new Map(); + for (const dim of plan.dimensions) { + if (dim.field !== dim.alias) renameMap.set(dim.field, dim.alias); + } + for (const m of plan.measures) { + if (m.alias !== m.field) renameMap.set(m.alias, m.alias); // keep alias if already present + } + + if (renameMap.size === 0) return rows; + + return rows.map(row => { + const out: Record = {}; + for (const [key, val] of Object.entries(row)) { + const alias = renameMap.get(key); + out[alias || key] = val; + } + return out; + }); + } + + private buildFields(plan: LogicalPlan): Array<{ name: string; type: string }> { + const fields: Array<{ name: string; type: string }> = []; + for (const dim of plan.dimensions) { + fields.push({ name: dim.alias, type: 'string' }); + } + for (const m of plan.measures) { + fields.push({ name: m.alias, type: 'number' }); + } + return fields; + } +} diff --git a/packages/foundation/plugin-analytics/src/strategy-sql.ts b/packages/foundation/plugin-analytics/src/strategy-sql.ts new file mode 100644 index 00000000..a3795343 --- /dev/null +++ b/packages/foundation/plugin-analytics/src/strategy-sql.ts @@ -0,0 +1,247 @@ +/** + * ObjectQL Plugin Analytics — NativeSQLStrategy + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AnalyticsResult } from '@objectql/types'; +import type { AnalyticsStrategy, LogicalPlan, LogicalPlanMeasure } from './types'; + +/** + * NativeSQLStrategy + * + * Pushes the LogicalPlan down to a SQL driver by building a raw SQL query + * using the driver's Knex instance. Supports SQL aggregate push-down for + * Postgres, SQLite, MySQL, and other Knex-supported dialects. + */ +export class NativeSQLStrategy implements AnalyticsStrategy { + readonly name = 'native-sql'; + + async execute(plan: LogicalPlan, driver: unknown): Promise { + const knex = this.getKnex(driver); + const { sql, params } = this.buildQuery(plan, knex); + const rows = await knex.raw(sql, params).then((result: any) => { + // Knex wraps results differently depending on dialect + return Array.isArray(result) ? result : (result.rows || result); + }); + + return { + rows: rows as Record[], + fields: this.buildFields(plan), + sql, + }; + } + + generateSql(plan: LogicalPlan): { sql: string; params: unknown[] } { + // Build SQL without a live knex instance — uses placeholder dialect + return this.buildQueryPlain(plan); + } + + // ------------------------------------------------------------------- + // SQL Generation + // ------------------------------------------------------------------- + + buildQuery(plan: LogicalPlan, knex: any): { sql: string; params: unknown[] } { + const builder = knex(plan.objectName); + + // SELECT — dimensions as group-by columns + for (const dim of plan.dimensions) { + builder.select(`${dim.field} as ${dim.alias}`); + } + + // SELECT — aggregate functions + for (const m of plan.measures) { + const expr = this.aggregateExpression(m); + builder.select(knex.raw(`${expr} as ??`, [m.alias])); + } + + // WHERE + this.applyFilters(builder, plan); + + // GROUP BY + if (plan.dimensions.length > 0) { + builder.groupBy(plan.dimensions.map(d => d.field)); + } + + // ORDER BY + if (plan.order) { + for (const [key, dir] of Object.entries(plan.order)) { + builder.orderBy(key, dir); + } + } + + // LIMIT / OFFSET + if (plan.limit != null) builder.limit(plan.limit); + if (plan.offset != null) builder.offset(plan.offset); + + const compiled = builder.toSQL(); + return { sql: compiled.sql, params: compiled.bindings }; + } + + /** Generate SQL as a plain string (no live driver). */ + private buildQueryPlain(plan: LogicalPlan): { sql: string; params: unknown[] } { + const selectParts: string[] = []; + const params: unknown[] = []; + + for (const dim of plan.dimensions) { + selectParts.push(`"${dim.field}" as "${dim.alias}"`); + } + for (const m of plan.measures) { + selectParts.push(`${this.aggregateExpression(m)} as "${m.alias}"`); + } + + let sql = `SELECT ${selectParts.join(', ')} FROM "${plan.objectName}"`; + + // WHERE + const whereClauses = this.buildWhereClauses(plan, params); + if (whereClauses.length > 0) { + sql += ` WHERE ${whereClauses.join(' AND ')}`; + } + + // GROUP BY + if (plan.dimensions.length > 0) { + sql += ` GROUP BY ${plan.dimensions.map(d => `"${d.field}"`).join(', ')}`; + } + + // ORDER BY + if (plan.order) { + const orderParts = Object.entries(plan.order).map(([k, v]) => `"${k}" ${v}`); + if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(', ')}`; + } + + if (plan.limit != null) sql += ` LIMIT ${plan.limit}`; + if (plan.offset != null) sql += ` OFFSET ${plan.offset}`; + + return { sql, params }; + } + + // ------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------- + + private aggregateExpression(m: LogicalPlanMeasure): string { + switch (m.aggregation) { + case 'count': + return m.field === '*' ? 'count(*)' : `count("${m.field}")`; + case 'countDistinct': + return `count(distinct "${m.field}")`; + case 'sum': + return `sum("${m.field}")`; + case 'avg': + return `avg("${m.field}")`; + case 'min': + return `min("${m.field}")`; + case 'max': + return `max("${m.field}")`; + default: + return `count(*)`; + } + } + + private applyFilters(builder: any, plan: LogicalPlan): void { + for (const f of plan.filters) { + const values = f.values || []; + switch (f.operator) { + case 'equals': + builder.where(f.field, values[0]); + break; + case 'notEquals': + builder.whereNot(f.field, values[0]); + break; + case 'gt': + builder.where(f.field, '>', values[0]); + break; + case 'gte': + builder.where(f.field, '>=', values[0]); + break; + case 'lt': + builder.where(f.field, '<', values[0]); + break; + case 'lte': + builder.where(f.field, '<=', values[0]); + break; + case 'contains': + builder.where(f.field, 'like', `%${values[0]}%`); + break; + case 'in': + builder.whereIn(f.field, values); + break; + case 'notIn': + builder.whereNotIn(f.field, values); + break; + default: + if (values.length === 1) { + builder.where(f.field, values[0]); + } + } + } + } + + private buildWhereClauses(plan: LogicalPlan, params: unknown[]): string[] { + const clauses: string[] = []; + for (const f of plan.filters) { + const values = f.values || []; + switch (f.operator) { + case 'equals': + clauses.push(`"${f.field}" = ?`); + params.push(values[0]); + break; + case 'notEquals': + clauses.push(`"${f.field}" != ?`); + params.push(values[0]); + break; + case 'gt': + clauses.push(`"${f.field}" > ?`); + params.push(values[0]); + break; + case 'gte': + clauses.push(`"${f.field}" >= ?`); + params.push(values[0]); + break; + case 'lt': + clauses.push(`"${f.field}" < ?`); + params.push(values[0]); + break; + case 'lte': + clauses.push(`"${f.field}" <= ?`); + params.push(values[0]); + break; + case 'contains': + clauses.push(`"${f.field}" LIKE ?`); + params.push(`%${values[0]}%`); + break; + case 'in': + clauses.push(`"${f.field}" IN (${values.map(() => '?').join(', ')})`); + params.push(...values); + break; + default: + if (values.length === 1) { + clauses.push(`"${f.field}" = ?`); + params.push(values[0]); + } + } + } + return clauses; + } + + private buildFields(plan: LogicalPlan): Array<{ name: string; type: string }> { + const fields: Array<{ name: string; type: string }> = []; + for (const dim of plan.dimensions) { + fields.push({ name: dim.alias, type: 'string' }); + } + for (const m of plan.measures) { + fields.push({ name: m.alias, type: 'number' }); + } + return fields; + } + + private getKnex(driver: unknown): any { + const d = driver as any; + if (d.knex) return d.knex; + if (d.getKnex && typeof d.getKnex === 'function') return d.getKnex(); + if (d.connection) return d.connection; + throw new Error('NativeSQLStrategy requires a SQL driver with a knex instance.'); + } +} diff --git a/packages/foundation/plugin-analytics/src/types.ts b/packages/foundation/plugin-analytics/src/types.ts new file mode 100644 index 00000000..f601930f --- /dev/null +++ b/packages/foundation/plugin-analytics/src/types.ts @@ -0,0 +1,119 @@ +/** + * ObjectQL Plugin Analytics — Internal Types + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AnalyticsQuery, AnalyticsResult, CubeMeta } from '@objectql/types'; + +// ============================================================================ +// Cube Definition (manifest or inferred) +// ============================================================================ + +/** Measure definition within a cube */ +export interface CubeMeasure { + readonly name: string; + readonly type: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'countDistinct'; + /** Underlying field name in the datasource. Defaults to the measure name. */ + readonly field?: string; + readonly title?: string; +} + +/** Dimension definition within a cube */ +export interface CubeDimension { + readonly name: string; + readonly type: 'string' | 'number' | 'time' | 'boolean'; + /** Underlying field name in the datasource. Defaults to the dimension name. */ + readonly field?: string; + readonly title?: string; +} + +/** A cube definition — either declared in a manifest or inferred from metadata. */ +export interface CubeDefinition { + /** Cube name — must be unique within the registry. */ + readonly name: string; + /** Human-readable title */ + readonly title?: string; + /** Underlying datasource object name (e.g. table or collection). */ + readonly objectName: string; + /** Optional datasource key (defaults to 'default'). */ + readonly datasource?: string; + readonly measures: readonly CubeMeasure[]; + readonly dimensions: readonly CubeDimension[]; +} + +// ============================================================================ +// Logical Plan — driver-agnostic intermediate representation +// ============================================================================ + +export interface LogicalPlanMeasure { + readonly cube: string; + readonly measure: string; + readonly aggregation: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'countDistinct'; + readonly field: string; + readonly alias: string; +} + +export interface LogicalPlanDimension { + readonly cube: string; + readonly dimension: string; + readonly field: string; + readonly alias: string; +} + +export interface LogicalPlanFilter { + readonly field: string; + readonly operator: string; + readonly values?: string[]; +} + +export interface LogicalPlanTimeDimension { + readonly field: string; + readonly granularity?: string; + readonly dateRange?: string | string[]; + readonly alias: string; +} + +export interface LogicalPlan { + readonly objectName: string; + readonly datasource: string; + readonly measures: readonly LogicalPlanMeasure[]; + readonly dimensions: readonly LogicalPlanDimension[]; + readonly filters: readonly LogicalPlanFilter[]; + readonly timeDimensions: readonly LogicalPlanTimeDimension[]; + readonly order?: Record; + readonly limit?: number; + readonly offset?: number; + readonly timezone?: string; +} + +// ============================================================================ +// Strategy interface — Physical compilation per driver type +// ============================================================================ + +export interface AnalyticsStrategy { + readonly name: string; + execute(plan: LogicalPlan, driver: unknown): Promise; + generateSql?(plan: LogicalPlan): { sql: string; params: unknown[] }; +} + +// ============================================================================ +// Plugin configuration +// ============================================================================ + +export interface AnalyticsPluginConfig { + /** Pre-registered cube manifests. */ + readonly cubes?: readonly CubeDefinition[]; + /** When true, cubes are automatically inferred from registered metadata objects. Defaults to true. */ + readonly autoDiscover?: boolean; + /** Map of datasource name → driver instance. Populated from kernel if omitted. */ + readonly datasources?: Record; +} + +// ============================================================================ +// Re-exports for consumer convenience +// ============================================================================ + +export type { AnalyticsQuery, AnalyticsResult, CubeMeta }; diff --git a/packages/foundation/plugin-analytics/test/analytics.test.ts b/packages/foundation/plugin-analytics/test/analytics.test.ts new file mode 100644 index 00000000..ed913c78 --- /dev/null +++ b/packages/foundation/plugin-analytics/test/analytics.test.ts @@ -0,0 +1,763 @@ +/** + * ObjectQL Plugin Analytics — Integration Tests + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { CubeRegistry } from '../src/cube-registry'; +import { SemanticCompiler } from '../src/semantic-compiler'; +import { NativeSQLStrategy } from '../src/strategy-sql'; +import { ObjectQLStrategy } from '../src/strategy-objectql'; +import { MemoryFallbackStrategy } from '../src/strategy-memory'; +import { AnalyticsService } from '../src/analytics-service'; +import { AnalyticsPlugin } from '../src/plugin'; +import type { CubeDefinition, AnalyticsQuery, LogicalPlan } from '../src/types'; + +// ============================================================================ +// Fixtures +// ============================================================================ + +const ordersCube: CubeDefinition = { + name: 'orders', + title: 'Orders', + objectName: 'orders', + measures: [ + { name: 'count', type: 'count', field: '*' }, + { name: 'totalAmount', type: 'sum', field: 'amount' }, + { name: 'avgAmount', type: 'avg', field: 'amount' }, + ], + dimensions: [ + { name: 'status', type: 'string', field: 'status' }, + { name: 'region', type: 'string', field: 'region' }, + { name: 'createdAt', type: 'time', field: 'created_at' }, + ], +}; + +const sampleRows = [ + { _id: '1', status: 'active', region: 'US', amount: 100, created_at: '2025-01-01' }, + { _id: '2', status: 'active', region: 'US', amount: 200, created_at: '2025-01-02' }, + { _id: '3', status: 'cancelled', region: 'EU', amount: 50, created_at: '2025-01-03' }, + { _id: '4', status: 'active', region: 'EU', amount: 150, created_at: '2025-01-04' }, + { _id: '5', status: 'cancelled', region: 'US', amount: 75, created_at: '2025-01-05' }, +]; + +/** Mock driver with find() only — simulates a basic driver */ +function createMockFindDriver(rows: Record[] = sampleRows) { + return { + name: 'mock-find', + supports: { queryAggregations: false }, + find: async (_objectName: string, _query: any) => [...rows], + }; +} + +/** Mock driver with aggregate() — simulates a MongoDB-like driver */ +function createMockAggregateDriver(rows: Record[] = sampleRows) { + return { + name: 'mock-aggregate', + supports: { queryAggregations: true }, + aggregate: async (_objectName: string, query: any) => { + // Simple in-JS aggregation for test verification + const groupBy = query.groupBy as string[] | undefined; + const aggregations = query.aggregations as Array<{ function: string; field: string; alias: string }>; + + if (!groupBy || groupBy.length === 0) { + const result: Record = {}; + for (const agg of aggregations) { + if (agg.function === 'count') { + result[agg.alias] = rows.length; + } else if (agg.function === 'sum') { + result[agg.alias] = rows.reduce((s, r) => s + (r[agg.field] as number || 0), 0); + } else if (agg.function === 'avg') { + const vals = rows.map(r => r[agg.field] as number).filter(v => typeof v === 'number'); + result[agg.alias] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + } + } + return [result]; + } + + // Group + const groups = new Map[]>(); + for (const row of rows) { + const key = groupBy.map(g => String(row[g])).join('|||'); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + + const results: Record[] = []; + for (const [, groupRows] of groups) { + const result: Record = {}; + for (const g of groupBy) { + result[g] = groupRows[0][g]; + } + for (const agg of aggregations) { + if (agg.function === 'count') { + result[agg.alias] = groupRows.length; + } else if (agg.function === 'sum') { + result[agg.alias] = groupRows.reduce((s, r) => s + (r[agg.field] as number || 0), 0); + } else if (agg.function === 'avg') { + const vals = groupRows.map(r => r[agg.field] as number).filter(v => typeof v === 'number'); + result[agg.alias] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + } + } + results.push(result); + } + return results; + }, + find: async () => [...rows], + }; +} + +// ============================================================================ +// CubeRegistry Tests +// ============================================================================ + +describe('CubeRegistry', () => { + let registry: CubeRegistry; + + beforeEach(() => { + registry = new CubeRegistry(); + }); + + it('should register and retrieve a cube by name', () => { + registry.register(ordersCube); + expect(registry.get('orders')).toEqual(ordersCube); + }); + + it('should list all registered cubes', () => { + registry.register(ordersCube); + registry.register({ ...ordersCube, name: 'products', objectName: 'products' }); + expect(registry.list()).toHaveLength(2); + }); + + it('should convert CubeDefinition to CubeMeta', () => { + registry.register(ordersCube); + const meta = registry.getMeta('orders'); + expect(meta).toHaveLength(1); + expect(meta[0].name).toBe('orders'); + expect(meta[0].measures).toHaveLength(3); + expect(meta[0].measures[0].name).toBe('orders.count'); + expect(meta[0].dimensions).toHaveLength(3); + expect(meta[0].dimensions[0].name).toBe('orders.status'); + }); + + it('should return empty array for unknown cube in getMeta', () => { + expect(registry.getMeta('nonexistent')).toEqual([]); + }); + + it('should return all cubes when getMeta is called without a name', () => { + registry.register(ordersCube); + registry.register({ ...ordersCube, name: 'products', objectName: 'products' }); + const meta = registry.getMeta(); + expect(meta).toHaveLength(2); + }); + + it('should auto-discover cubes from metadata', () => { + const metadata = { + list: (_type: string) => [ + { + name: 'invoices', + fields: { + total: { type: 'number' }, + status: { type: 'select' }, + createdAt: { type: 'datetime' }, + }, + }, + ], + }; + registry.discoverFromMetadata(metadata as any); + const cube = registry.get('invoices'); + expect(cube).toBeDefined(); + expect(cube!.measures.length).toBeGreaterThanOrEqual(1); // at least count + expect(cube!.dimensions.length).toBeGreaterThanOrEqual(1); + }); + + it('should not overwrite manifest cubes during auto-discovery', () => { + registry.register(ordersCube); + const metadata = { + list: () => [{ name: 'orders', fields: { foo: { type: 'string' } } }], + }; + registry.discoverFromMetadata(metadata as any); + // Should keep the manifest cube intact + expect(registry.get('orders')).toEqual(ordersCube); + }); +}); + +// ============================================================================ +// SemanticCompiler Tests +// ============================================================================ + +describe('SemanticCompiler', () => { + let registry: CubeRegistry; + let compiler: SemanticCompiler; + + beforeEach(() => { + registry = new CubeRegistry(); + registry.register(ordersCube); + compiler = new SemanticCompiler(registry); + }); + + it('should compile a simple count query', () => { + const query: AnalyticsQuery = { + cube: 'orders', + measures: ['orders.count'], + }; + const plan = compiler.compile(query); + expect(plan.objectName).toBe('orders'); + expect(plan.measures).toHaveLength(1); + expect(plan.measures[0].aggregation).toBe('count'); + expect(plan.measures[0].field).toBe('*'); + }); + + it('should compile query with dimensions and group-by', () => { + const query: AnalyticsQuery = { + cube: 'orders', + measures: ['orders.totalAmount'], + dimensions: ['orders.status'], + }; + const plan = compiler.compile(query); + expect(plan.measures[0].aggregation).toBe('sum'); + expect(plan.dimensions).toHaveLength(1); + expect(plan.dimensions[0].field).toBe('status'); + }); + + it('should compile query with filters', () => { + const query: AnalyticsQuery = { + cube: 'orders', + measures: ['orders.count'], + filters: [ + { member: 'orders.status', operator: 'equals', values: ['active'] }, + ], + }; + const plan = compiler.compile(query); + expect(plan.filters).toHaveLength(1); + expect(plan.filters[0].field).toBe('status'); + expect(plan.filters[0].operator).toBe('equals'); + }); + + it('should infer cube name from measure reference', () => { + const query: AnalyticsQuery = { + measures: ['orders.count'], + }; + const plan = compiler.compile(query); + expect(plan.objectName).toBe('orders'); + }); + + it('should throw for unknown cube', () => { + const query: AnalyticsQuery = { + cube: 'nonexistent', + measures: ['nonexistent.count'], + }; + expect(() => compiler.compile(query)).toThrow(/Cube 'nonexistent' is not registered/); + }); + + it('should throw for unknown measure', () => { + const query: AnalyticsQuery = { + cube: 'orders', + measures: ['orders.unknownMeasure'], + }; + expect(() => compiler.compile(query)).toThrow(/Measure.*not found/); + }); + + it('should throw for unknown dimension', () => { + const query: AnalyticsQuery = { + cube: 'orders', + measures: ['orders.count'], + dimensions: ['orders.unknownDim'], + }; + expect(() => compiler.compile(query)).toThrow(/Dimension.*not found/); + }); + + it('should handle limit, offset, and order', () => { + const query: AnalyticsQuery = { + cube: 'orders', + measures: ['orders.count'], + limit: 10, + offset: 5, + order: { 'orders__count': 'desc' }, + }; + const plan = compiler.compile(query); + expect(plan.limit).toBe(10); + expect(plan.offset).toBe(5); + expect(plan.order).toEqual({ 'orders__count': 'desc' }); + }); +}); + +// ============================================================================ +// MemoryFallbackStrategy Tests +// ============================================================================ + +describe('MemoryFallbackStrategy', () => { + let strategy: MemoryFallbackStrategy; + + beforeEach(() => { + strategy = new MemoryFallbackStrategy(); + }); + + it('should compute count(*)', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [], + filters: [], + timeDimensions: [], + }; + const driver = createMockFindDriver(); + const result = await strategy.execute(plan, driver); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]['orders__count']).toBe(5); + }); + + it('should compute sum grouped by dimension', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'totalAmount', aggregation: 'sum', field: 'amount', alias: 'orders__totalAmount' }], + dimensions: [{ cube: 'orders', dimension: 'status', field: 'status', alias: 'orders__status' }], + filters: [], + timeDimensions: [], + }; + const driver = createMockFindDriver(); + const result = await strategy.execute(plan, driver); + expect(result.rows).toHaveLength(2); // active + cancelled + + const active = result.rows.find(r => r['orders__status'] === 'active'); + const cancelled = result.rows.find(r => r['orders__status'] === 'cancelled'); + expect(active).toBeDefined(); + expect(cancelled).toBeDefined(); + expect(active!['orders__totalAmount']).toBe(450); // 100+200+150 + expect(cancelled!['orders__totalAmount']).toBe(125); // 50+75 + }); + + it('should compute avg', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'avgAmount', aggregation: 'avg', field: 'amount', alias: 'orders__avgAmount' }], + dimensions: [], + filters: [], + timeDimensions: [], + }; + const driver = createMockFindDriver(); + const result = await strategy.execute(plan, driver); + expect(result.rows[0]['orders__avgAmount']).toBe(115); // (100+200+50+150+75)/5 + }); + + it('should compute min and max', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [ + { cube: 'orders', measure: 'minAmt', aggregation: 'min', field: 'amount', alias: 'orders__minAmt' }, + { cube: 'orders', measure: 'maxAmt', aggregation: 'max', field: 'amount', alias: 'orders__maxAmt' }, + ], + dimensions: [], + filters: [], + timeDimensions: [], + }; + const driver = createMockFindDriver(); + const result = await strategy.execute(plan, driver); + expect(result.rows[0]['orders__minAmt']).toBe(50); + expect(result.rows[0]['orders__maxAmt']).toBe(200); + }); + + it('should respect limit and offset', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [{ cube: 'orders', dimension: 'status', field: 'status', alias: 'orders__status' }], + filters: [], + timeDimensions: [], + limit: 1, + offset: 0, + }; + const driver = createMockFindDriver(); + const result = await strategy.execute(plan, driver); + expect(result.rows).toHaveLength(1); + }); + + it('should return correct field metadata', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [{ cube: 'orders', dimension: 'status', field: 'status', alias: 'orders__status' }], + filters: [], + timeDimensions: [], + }; + const driver = createMockFindDriver(); + const result = await strategy.execute(plan, driver); + expect(result.fields).toEqual([ + { name: 'orders__status', type: 'string' }, + { name: 'orders__count', type: 'number' }, + ]); + }); +}); + +// ============================================================================ +// ObjectQLStrategy Tests +// ============================================================================ + +describe('ObjectQLStrategy', () => { + let strategy: ObjectQLStrategy; + + beforeEach(() => { + strategy = new ObjectQLStrategy(); + }); + + it('should execute aggregate via driver.aggregate()', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [], + filters: [], + timeDimensions: [], + }; + const driver = createMockAggregateDriver(); + const result = await strategy.execute(plan, driver); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]['orders__count']).toBe(5); + }); + + it('should group by dimension via driver.aggregate()', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'totalAmount', aggregation: 'sum', field: 'amount', alias: 'orders__totalAmount' }], + dimensions: [{ cube: 'orders', dimension: 'status', field: 'status', alias: 'orders__status' }], + filters: [], + timeDimensions: [], + }; + const driver = createMockAggregateDriver(); + const result = await strategy.execute(plan, driver); + expect(result.rows.length).toBeGreaterThanOrEqual(2); + }); + + it('should throw when driver lacks aggregate()', async () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [], + filters: [], + timeDimensions: [], + }; + const driver = { find: async () => [] }; // no aggregate + await expect(strategy.execute(plan, driver)).rejects.toThrow(/aggregate/); + }); +}); + +// ============================================================================ +// NativeSQLStrategy Tests (generateSql dry-run) +// ============================================================================ + +describe('NativeSQLStrategy', () => { + let strategy: NativeSQLStrategy; + + beforeEach(() => { + strategy = new NativeSQLStrategy(); + }); + + it('should generate SQL for a simple count query', () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [], + filters: [], + timeDimensions: [], + }; + const { sql } = strategy.generateSql(plan); + expect(sql).toContain('SELECT'); + expect(sql).toContain('count(*)'); + expect(sql).toContain('"orders"'); + }); + + it('should generate SQL with GROUP BY', () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'totalAmount', aggregation: 'sum', field: 'amount', alias: 'orders__totalAmount' }], + dimensions: [{ cube: 'orders', dimension: 'status', field: 'status', alias: 'orders__status' }], + filters: [], + timeDimensions: [], + }; + const { sql } = strategy.generateSql(plan); + expect(sql).toContain('GROUP BY'); + expect(sql).toContain('sum("amount")'); + expect(sql).toContain('"status"'); + }); + + it('should generate SQL with WHERE clause', () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [], + filters: [{ field: 'status', operator: 'equals', values: ['active'] }], + timeDimensions: [], + }; + const { sql, params } = strategy.generateSql(plan); + expect(sql).toContain('WHERE'); + expect(params).toContain('active'); + }); + + it('should generate SQL with LIMIT and OFFSET', () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [], + filters: [], + timeDimensions: [], + limit: 10, + offset: 20, + }; + const { sql } = strategy.generateSql(plan); + expect(sql).toContain('LIMIT 10'); + expect(sql).toContain('OFFSET 20'); + }); + + it('should generate SQL with ORDER BY', () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [], + filters: [], + timeDimensions: [], + order: { 'orders__count': 'desc' }, + }; + const { sql } = strategy.generateSql(plan); + expect(sql).toContain('ORDER BY'); + expect(sql).toContain('desc'); + }); + + it('should handle IN filter with multiple values', () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'count', aggregation: 'count', field: '*', alias: 'orders__count' }], + dimensions: [], + filters: [{ field: 'status', operator: 'in', values: ['active', 'cancelled'] }], + timeDimensions: [], + }; + const { sql, params } = strategy.generateSql(plan); + expect(sql).toContain('IN'); + expect(params).toContain('active'); + expect(params).toContain('cancelled'); + }); + + it('should generate countDistinct SQL', () => { + const plan: LogicalPlan = { + objectName: 'orders', + datasource: 'default', + measures: [{ cube: 'orders', measure: 'uniqueStatus', aggregation: 'countDistinct', field: 'status', alias: 'orders__uniqueStatus' }], + dimensions: [], + filters: [], + timeDimensions: [], + }; + const { sql } = strategy.generateSql(plan); + expect(sql).toContain('count(distinct "status")'); + }); +}); + +// ============================================================================ +// AnalyticsService Tests +// ============================================================================ + +describe('AnalyticsService', () => { + let registry: CubeRegistry; + let service: AnalyticsService; + + beforeEach(() => { + registry = new CubeRegistry(); + registry.register(ordersCube); + }); + + it('should dispatch to MemoryFallbackStrategy for basic drivers', async () => { + const driver = createMockFindDriver(); + service = new AnalyticsService(registry, { default: driver }); + + const result = await service.query({ + cube: 'orders', + measures: ['orders.count'], + }); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0]['orders__count']).toBe(5); + }); + + it('should dispatch to ObjectQLStrategy for aggregate-capable drivers', async () => { + const driver = createMockAggregateDriver(); + service = new AnalyticsService(registry, { default: driver }); + + const result = await service.query({ + cube: 'orders', + measures: ['orders.count'], + }); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0]['orders__count']).toBe(5); + }); + + it('should correctly select strategy based on driver capabilities', () => { + const findDriver = createMockFindDriver(); + const aggDriver = createMockAggregateDriver(); + const sqlDriver = { knex: {}, find: async () => [], supports: { queryAggregations: true } }; + + service = new AnalyticsService(registry, { default: findDriver }); + expect(service.selectStrategy(findDriver).name).toBe('memory-fallback'); + expect(service.selectStrategy(aggDriver).name).toBe('objectql-aggregate'); + expect(service.selectStrategy(sqlDriver).name).toBe('native-sql'); + }); + + it('should return getMeta for registered cubes', async () => { + service = new AnalyticsService(registry, { default: createMockFindDriver() }); + const meta = await service.getMeta(); + expect(meta).toHaveLength(1); + expect(meta[0].name).toBe('orders'); + }); + + it('should return getMeta for specific cube', async () => { + service = new AnalyticsService(registry, { default: createMockFindDriver() }); + const meta = await service.getMeta('orders'); + expect(meta).toHaveLength(1); + }); + + it('should generate SQL via generateSql()', async () => { + service = new AnalyticsService(registry, { default: createMockFindDriver() }); + const { sql } = await service.generateSql({ + cube: 'orders', + measures: ['orders.count'], + }); + expect(sql).toContain('count(*)'); + expect(sql).toContain('"orders"'); + }); + + it('should throw for unknown datasource', async () => { + const cube: CubeDefinition = { + ...ordersCube, + name: 'remote', + objectName: 'remote', + datasource: 'remote-db', + }; + registry.register(cube); + service = new AnalyticsService(registry, { default: createMockFindDriver() }); + + await expect(service.query({ + cube: 'remote', + measures: ['remote.count'], + })).rejects.toThrow(/Datasource 'remote-db' not found/); + }); + + it('should support grouped query with filter via MemoryFallback', async () => { + const driver = createMockFindDriver(); + service = new AnalyticsService(registry, { default: driver }); + + const result = await service.query({ + cube: 'orders', + measures: ['orders.totalAmount'], + dimensions: ['orders.status'], + }); + + expect(result.rows.length).toBeGreaterThanOrEqual(2); + expect(result.fields).toEqual( + expect.arrayContaining([ + { name: 'orders__status', type: 'string' }, + { name: 'orders__totalAmount', type: 'number' }, + ]), + ); + }); +}); + +// ============================================================================ +// AnalyticsPlugin Tests +// ============================================================================ + +describe('AnalyticsPlugin', () => { + it('should have correct name and version', () => { + const plugin = new AnalyticsPlugin(); + expect(plugin.name).toBe('@objectql/plugin-analytics'); + expect(plugin.version).toBe('4.2.2'); + }); + + it('should install and register service on kernel', async () => { + const kernel: any = { + getAllDrivers: () => [createMockFindDriver()], + metadata: { + list: () => [], + }, + }; + + let registeredService: any = null; + const ctx: any = { + engine: kernel, + registerService: (_name: string, svc: any) => { + registeredService = svc; + }, + }; + + const plugin = new AnalyticsPlugin({ cubes: [ordersCube] }); + await plugin.install(ctx); + + expect(kernel.analyticsService).toBeDefined(); + expect(registeredService).toBeDefined(); + }); + + it('should warn when no datasources are available', async () => { + const kernel: any = { getAllDrivers: () => [] }; + const ctx: any = { engine: kernel }; + + const plugin = new AnalyticsPlugin(); + // Should not throw + await plugin.install(ctx); + expect(kernel.analyticsService).toBeUndefined(); + }); + + it('should auto-discover cubes from metadata', async () => { + const kernel: any = { + getAllDrivers: () => [createMockFindDriver()], + metadata: { + list: () => [ + { + name: 'products', + fields: { + price: { type: 'number' }, + category: { type: 'string' }, + }, + }, + ], + }, + }; + const ctx: any = { engine: kernel, registerService: () => {} }; + + const plugin = new AnalyticsPlugin({ autoDiscover: true }); + await plugin.install(ctx); + + expect(kernel.analyticsService).toBeDefined(); + const meta = await kernel.analyticsService.getMeta('products'); + expect(meta).toHaveLength(1); + expect(meta[0].name).toBe('products'); + }); + + it('should support the init() adapter for @objectstack/core', async () => { + const kernel: any = { + getAllDrivers: () => [createMockFindDriver()], + metadata: { list: () => [] }, + }; + const pluginCtx: any = { + getKernel: () => kernel, + registerService: () => {}, + }; + + const plugin = new AnalyticsPlugin({ cubes: [ordersCube] }); + await plugin.init(pluginCtx); + expect(kernel.analyticsService).toBeDefined(); + }); +}); diff --git a/packages/foundation/plugin-analytics/tsconfig.json b/packages/foundation/plugin-analytics/tsconfig.json new file mode 100644 index 00000000..588ec401 --- /dev/null +++ b/packages/foundation/plugin-analytics/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../types" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5be4698b..1f243298 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -801,6 +801,19 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/foundation/plugin-analytics: + dependencies: + '@objectql/types': + specifier: workspace:* + version: link:../types + '@objectstack/spec': + specifier: ^3.2.8 + version: 3.2.8 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/foundation/plugin-formula: dependencies: '@objectql/types': diff --git a/vitest.config.ts b/vitest.config.ts index d17978cd..01499fa3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ '@objectql/plugin-sync': path.resolve(__dirname, './packages/foundation/plugin-sync/src'), '@objectql/plugin-query': path.resolve(__dirname, './packages/foundation/plugin-query/src'), '@objectql/plugin-optimizations': path.resolve(__dirname, './packages/foundation/plugin-optimizations/src'), + '@objectql/plugin-analytics': path.resolve(__dirname, './packages/foundation/plugin-analytics/src'), '@objectql/edge-adapter': path.resolve(__dirname, './packages/foundation/edge-adapter/src'), '@objectql/protocol-graphql': path.resolve(__dirname, './packages/protocols/graphql/src'), '@objectql/protocol-odata-v4': path.resolve(__dirname, './packages/protocols/odata-v4/src'),