Skip to content

Commit 6812ef3

Browse files
Copilothotlong
andcommitted
feat: implement @objectql/plugin-analytics with multi-database analytical queries
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
1 parent 0014144 commit 6812ef3

File tree

16 files changed

+2162
-0
lines changed

16 files changed

+2162
-0
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`@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:
13+
- `NativeSQLStrategy` — pushes analytics to SQL databases via Knex (Postgres, SQLite, MySQL).
14+
- `ObjectQLStrategy` — delegates to driver's native `aggregate()` method (MongoDB, etc.).
15+
- `MemoryFallbackStrategy` — in-memory aggregation for dev/test environments.
16+
- `CubeRegistry` — supports manifest-based and automatic model/metadata-inferred cube definitions.
17+
- `SemanticCompiler` — compiles `AnalyticsQuery` + `CubeDefinition` into driver-agnostic `LogicalPlan`.
18+
- `generateSql()` — SQL dry-run/explanation support for query debugging.
19+
- `AnalyticsPlugin` — kernel plugin registering `'analytics'` service for REST API discovery.
20+
1021
### Fixed
1122

1223
- **`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.

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,7 @@ Standardize third-party plugin distribution.
980980
| `packages/foundation/plugin-workflow` | `@objectql/plugin-workflow` | Universal | State machine executor with guards, actions, compound states. |
981981
| `packages/foundation/plugin-multitenancy` | `@objectql/plugin-multitenancy` | Universal | Tenant isolation via hook-based filter rewriting. |
982982
| `packages/foundation/plugin-sync` | `@objectql/plugin-sync` | Universal | Offline-first sync engine with mutation logging and conflict resolution. |
983+
| `packages/foundation/plugin-analytics` | `@objectql/plugin-analytics` | Universal | Analytics/BI plugin with multi-database strategy dispatch (SQL, Mongo, Memory). |
983984
| `packages/foundation/edge-adapter` | `@objectql/edge-adapter` | Universal | Edge runtime detection and capability validation. |
984985

985986
### Driver Layer
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@objectql/plugin-analytics",
3+
"version": "4.2.2",
4+
"description": "Analytics and BI plugin for ObjectQL — multi-database analytical queries with strategy-based driver dispatch",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"sideEffects": false,
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"default": "./dist/index.js"
12+
}
13+
},
14+
"files": [
15+
"dist"
16+
],
17+
"scripts": {
18+
"build": "tsc"
19+
},
20+
"dependencies": {
21+
"@objectql/types": "workspace:*",
22+
"@objectstack/spec": "^3.2.8"
23+
},
24+
"devDependencies": {
25+
"typescript": "^5.9.3"
26+
}
27+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* ObjectQL Plugin Analytics — AnalyticsService
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 { IAnalyticsService, AnalyticsQuery, AnalyticsResult, CubeMeta } from '@objectql/types';
10+
import { ObjectQLError } from '@objectql/types';
11+
import type { AnalyticsStrategy } from './types';
12+
import { CubeRegistry } from './cube-registry';
13+
import { SemanticCompiler } from './semantic-compiler';
14+
import { NativeSQLStrategy } from './strategy-sql';
15+
import { ObjectQLStrategy } from './strategy-objectql';
16+
import { MemoryFallbackStrategy } from './strategy-memory';
17+
18+
/**
19+
* AnalyticsService
20+
*
21+
* Implements IAnalyticsService from @objectstack/spec.
22+
* Dispatches analytics queries through a strategy pipeline:
23+
*
24+
* 1. SemanticCompiler → LogicalPlan
25+
* 2. Strategy selection (based on driver capabilities)
26+
* 3. Strategy.execute → AnalyticsResult
27+
*
28+
* Strategy selection order:
29+
* a. NativeSQLStrategy — if driver exposes a `knex` instance (SQL push-down)
30+
* b. ObjectQLStrategy — if driver supports `aggregate()` + `queryAggregations`
31+
* c. MemoryFallbackStrategy — fallback for dev/test (fetch all → JS aggregation)
32+
*/
33+
export class AnalyticsService implements IAnalyticsService {
34+
private readonly compiler: SemanticCompiler;
35+
private readonly sqlStrategy = new NativeSQLStrategy();
36+
private readonly objectqlStrategy = new ObjectQLStrategy();
37+
private readonly memoryStrategy = new MemoryFallbackStrategy();
38+
39+
constructor(
40+
readonly registry: CubeRegistry,
41+
private readonly datasources: Record<string, unknown>,
42+
) {
43+
this.compiler = new SemanticCompiler(registry);
44+
}
45+
46+
// -------------------------------------------------------------------
47+
// IAnalyticsService implementation
48+
// -------------------------------------------------------------------
49+
50+
async query(query: AnalyticsQuery): Promise<AnalyticsResult> {
51+
const plan = this.compiler.compile(query);
52+
const driver = this.resolveDriver(plan.datasource);
53+
const strategy = this.selectStrategy(driver);
54+
return strategy.execute(plan, driver);
55+
}
56+
57+
async getMeta(cubeName?: string): Promise<CubeMeta[]> {
58+
return this.registry.getMeta(cubeName);
59+
}
60+
61+
async generateSql(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }> {
62+
const plan = this.compiler.compile(query);
63+
const driver = this.resolveDriver(plan.datasource);
64+
65+
// Prefer SQL strategy's generateSql with live knex for accurate dialect
66+
if (this.isSqlDriver(driver)) {
67+
return this.sqlStrategy.buildQuery(plan, this.getKnex(driver));
68+
}
69+
70+
// Fallback to plain SQL generation
71+
return this.sqlStrategy.generateSql(plan);
72+
}
73+
74+
// -------------------------------------------------------------------
75+
// Strategy selection
76+
// -------------------------------------------------------------------
77+
78+
selectStrategy(driver: unknown): AnalyticsStrategy {
79+
if (this.isSqlDriver(driver)) {
80+
return this.sqlStrategy;
81+
}
82+
if (this.supportsAggregation(driver)) {
83+
return this.objectqlStrategy;
84+
}
85+
return this.memoryStrategy;
86+
}
87+
88+
// -------------------------------------------------------------------
89+
// Driver helpers
90+
// -------------------------------------------------------------------
91+
92+
private resolveDriver(datasource: string): unknown {
93+
const driver = this.datasources[datasource];
94+
if (!driver) {
95+
throw new ObjectQLError({
96+
code: 'ANALYTICS_DATASOURCE_NOT_FOUND',
97+
message: `Datasource '${datasource}' not found. Available: ${Object.keys(this.datasources).join(', ') || '(none)'}`,
98+
});
99+
}
100+
return driver;
101+
}
102+
103+
private isSqlDriver(driver: unknown): boolean {
104+
const d = driver as any;
105+
return !!(d.knex || (typeof d.getKnex === 'function'));
106+
}
107+
108+
private supportsAggregation(driver: unknown): boolean {
109+
const d = driver as any;
110+
return (
111+
typeof d.aggregate === 'function' &&
112+
d.supports?.queryAggregations === true
113+
);
114+
}
115+
116+
private getKnex(driver: unknown): any {
117+
const d = driver as any;
118+
return d.knex || d.getKnex?.();
119+
}
120+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* ObjectQL Plugin Analytics
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+
* Multi-database analytical query plugin for ObjectQL.
9+
* Provides strategy-based driver dispatch for SQL, MongoDB, and in-memory engines.
10+
*
11+
* @example
12+
* ```typescript
13+
* import { AnalyticsPlugin } from '@objectql/plugin-analytics';
14+
*
15+
* const kernel = new ObjectStackKernel([
16+
* new AnalyticsPlugin({ autoDiscover: true }),
17+
* ]);
18+
* ```
19+
*/
20+
21+
export { AnalyticsPlugin } from './plugin';
22+
export { AnalyticsService } from './analytics-service';
23+
export { CubeRegistry } from './cube-registry';
24+
export { SemanticCompiler } from './semantic-compiler';
25+
export { NativeSQLStrategy } from './strategy-sql';
26+
export { ObjectQLStrategy } from './strategy-objectql';
27+
export { MemoryFallbackStrategy } from './strategy-memory';
28+
29+
export type {
30+
CubeDefinition,
31+
CubeMeasure,
32+
CubeDimension,
33+
LogicalPlan,
34+
LogicalPlanMeasure,
35+
LogicalPlanDimension,
36+
LogicalPlanFilter,
37+
LogicalPlanTimeDimension,
38+
AnalyticsStrategy,
39+
AnalyticsPluginConfig,
40+
} from './types';
41+
42+
// Re-export spec types for consumer convenience
43+
export type { AnalyticsQuery, AnalyticsResult, CubeMeta } from './types';

0 commit comments

Comments
 (0)