Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>@<ver>/node_modules/`) and copies any missing sibling dependency into the top-level `node_modules/`, repeating until the full transitive closure is present.
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions packages/foundation/plugin-analytics/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
120 changes: 120 additions & 0 deletions packages/foundation/plugin-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
) {
this.compiler = new SemanticCompiler(registry);
}

// -------------------------------------------------------------------
// IAnalyticsService implementation
// -------------------------------------------------------------------

async query(query: AnalyticsQuery): Promise<AnalyticsResult> {
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<CubeMeta[]> {
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?.();
}
}
153 changes: 153 additions & 0 deletions packages/foundation/plugin-analytics/src/cube-registry.ts
Original file line number Diff line number Diff line change
@@ -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<string, CubeDefinition>();

/** 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<string, unknown>).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';
}
}
43 changes: 43 additions & 0 deletions packages/foundation/plugin-analytics/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading