metal-orm 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/ROADMAP.md +125 -0
- package/metadata.json +5 -0
- package/package.json +45 -0
- package/playground/api/playground-api.ts +94 -0
- package/playground/index.html +15 -0
- package/playground/src/App.css +1 -0
- package/playground/src/App.tsx +114 -0
- package/playground/src/components/CodeDisplay.tsx +43 -0
- package/playground/src/components/QueryExecutor.tsx +189 -0
- package/playground/src/components/ResultsTable.tsx +67 -0
- package/playground/src/components/ResultsTabs.tsx +105 -0
- package/playground/src/components/ScenarioList.tsx +56 -0
- package/playground/src/components/logo.svg +45 -0
- package/playground/src/data/scenarios.ts +2 -0
- package/playground/src/main.tsx +9 -0
- package/playground/src/services/PlaygroundApiService.ts +60 -0
- package/postcss.config.cjs +5 -0
- package/sql_sql-ansi-cheatsheet-2025.md +264 -0
- package/src/ast/expression.ts +362 -0
- package/src/ast/join.ts +11 -0
- package/src/ast/query.ts +63 -0
- package/src/builder/hydration-manager.ts +55 -0
- package/src/builder/hydration-planner.ts +77 -0
- package/src/builder/operations/column-selector.ts +42 -0
- package/src/builder/operations/cte-manager.ts +18 -0
- package/src/builder/operations/filter-manager.ts +36 -0
- package/src/builder/operations/join-manager.ts +26 -0
- package/src/builder/operations/pagination-manager.ts +17 -0
- package/src/builder/operations/relation-manager.ts +49 -0
- package/src/builder/query-ast-service.ts +155 -0
- package/src/builder/relation-conditions.ts +39 -0
- package/src/builder/relation-projection-helper.ts +59 -0
- package/src/builder/relation-service.ts +166 -0
- package/src/builder/select-query-builder-deps.ts +33 -0
- package/src/builder/select-query-state.ts +107 -0
- package/src/builder/select.ts +237 -0
- package/src/codegen/typescript.ts +295 -0
- package/src/constants/sql.ts +57 -0
- package/src/dialect/abstract.ts +221 -0
- package/src/dialect/mssql/index.ts +89 -0
- package/src/dialect/mysql/index.ts +81 -0
- package/src/dialect/sqlite/index.ts +85 -0
- package/src/index.ts +12 -0
- package/src/playground/features/playground/api/types.ts +16 -0
- package/src/playground/features/playground/clients/MockClient.ts +17 -0
- package/src/playground/features/playground/clients/SqliteClient.ts +57 -0
- package/src/playground/features/playground/common/IDatabaseClient.ts +10 -0
- package/src/playground/features/playground/data/scenarios/aggregation.ts +36 -0
- package/src/playground/features/playground/data/scenarios/basics.ts +25 -0
- package/src/playground/features/playground/data/scenarios/edge_cases.ts +57 -0
- package/src/playground/features/playground/data/scenarios/filtering.ts +94 -0
- package/src/playground/features/playground/data/scenarios/hydration.ts +15 -0
- package/src/playground/features/playground/data/scenarios/index.ts +29 -0
- package/src/playground/features/playground/data/scenarios/ordering.ts +25 -0
- package/src/playground/features/playground/data/scenarios/pagination.ts +16 -0
- package/src/playground/features/playground/data/scenarios/relationships.ts +75 -0
- package/src/playground/features/playground/data/scenarios/types.ts +67 -0
- package/src/playground/features/playground/data/schema.ts +87 -0
- package/src/playground/features/playground/data/seed.ts +104 -0
- package/src/playground/features/playground/services/QueryExecutionService.ts +120 -0
- package/src/runtime/als.ts +19 -0
- package/src/runtime/hydration.ts +43 -0
- package/src/schema/column.ts +19 -0
- package/src/schema/relation.ts +38 -0
- package/src/schema/table.ts +22 -0
- package/tests/between.test.ts +43 -0
- package/tests/case-expression.test.ts +58 -0
- package/tests/complex-exists.test.ts +230 -0
- package/tests/cte.test.ts +118 -0
- package/tests/exists.test.ts +127 -0
- package/tests/like.test.ts +33 -0
- package/tests/right-join.test.ts +89 -0
- package/tests/subquery-having.test.ts +193 -0
- package/tests/window-function.test.ts +137 -0
- package/tsconfig.json +30 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +22 -0
- package/vitest.config.ts +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
# Run and deploy your AI Studio app
|
|
6
|
+
|
|
7
|
+
This contains everything you need to run your app locally.
|
|
8
|
+
|
|
9
|
+
View your app in AI Studio: https://ai.studio/apps/drive/1vXchpJ36cRXpwzrizwz6I-M1Ks1GAkz2
|
|
10
|
+
|
|
11
|
+
## Run Locally
|
|
12
|
+
|
|
13
|
+
**Prerequisites:** Node.js
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
1. Install dependencies:
|
|
17
|
+
`npm install`
|
|
18
|
+
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
|
19
|
+
3. Run the app:
|
|
20
|
+
`npm run dev`
|
|
21
|
+
|
|
22
|
+
## Project layout
|
|
23
|
+
|
|
24
|
+
- `src/` hosts the React-based playground UI that backs the demo.
|
|
25
|
+
- `src/metal-orm/src/` contains the real MetalORM implementation (AST, builder, dialects, runtime) consumed by the playground.
|
|
26
|
+
- Legacy `orm/` and `services/orm/` directories were removed because they were unused duplicates, so future work belongs in `src/metal-orm/src`.
|
|
27
|
+
|
|
28
|
+
## Parameterized Queries
|
|
29
|
+
|
|
30
|
+
Literal values in expressions are now emitted as parameter placeholders and collected in a binding list. Use `SelectQueryBuilder.compile(dialect)` to get `{ sql, params }` and pass both to your database driver (`client.executeSql(sql, params)`); `SelectQueryBuilder.toSql(dialect)` still returns just the SQL string for quick debugging.
|
package/ROADMAP.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Metal ORM Roadmap
|
|
2
|
+
|
|
3
|
+
## Current ORM Capabilities
|
|
4
|
+
|
|
5
|
+
The current implementation supports:
|
|
6
|
+
|
|
7
|
+
- Basic SELECT with projections and aliases
|
|
8
|
+
- INNER/LEFT JOINs with manual conditions
|
|
9
|
+
- Smart relationship joins via `joinRelation()`
|
|
10
|
+
- Eager loading with `include()` for 1:1 and 1:N relationships
|
|
11
|
+
- Basic WHERE clauses with operators (eq, gt, like, in, null checks)
|
|
12
|
+
- GROUP BY, ORDER BY, LIMIT, OFFSET
|
|
13
|
+
- Aggregation functions (COUNT, SUM, AVG)
|
|
14
|
+
- JSON path extraction (dialect-specific)
|
|
15
|
+
- Hydration of nested objects from flat SQL results
|
|
16
|
+
- EXISTS and NOT EXISTS subqueries
|
|
17
|
+
- Scalar correlated subqueries in SELECT and WHERE
|
|
18
|
+
- CASE expressions (simple and searched)
|
|
19
|
+
- HAVING clause for post-aggregation filtering
|
|
20
|
+
|
|
21
|
+
## Identified Absences
|
|
22
|
+
|
|
23
|
+
### 1. CTE (Common Table Expressions) ✅
|
|
24
|
+
|
|
25
|
+
**Completed:** Full CTE support has been implemented
|
|
26
|
+
|
|
27
|
+
- Features: Simple CTEs, Recursive CTEs, Multiple CTEs, CTE with column aliases
|
|
28
|
+
- **Implementation:** CTE AST node and dialect compilation for SQLite, MySQL, and MSSQL
|
|
29
|
+
- Recursive CTEs properly handle the `WITH RECURSIVE` keyword (SQLite/MySQL only, MSSQL uses plain `WITH`)
|
|
30
|
+
- Mixed recursive and non-recursive CTEs are supported
|
|
31
|
+
|
|
32
|
+
### 2. Window Functions ✅
|
|
33
|
+
|
|
34
|
+
**Completed:** Comprehensive window function support has been implemented
|
|
35
|
+
|
|
36
|
+
- Features: `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `LAG()`, `LEAD()`, `NTILE()`, `FIRST_VALUE()`, `LAST_VALUE()`
|
|
37
|
+
- **Implementation:** Window function AST nodes with `PARTITION BY` and `ORDER BY` support
|
|
38
|
+
- All three dialects (SQLite, MySQL, MSSQL) support window functions
|
|
39
|
+
|
|
40
|
+
### 3. RIGHT JOIN Support
|
|
41
|
+
|
|
42
|
+
**Missing:** Only INNER, LEFT, and CROSS joins are defined
|
|
43
|
+
|
|
44
|
+
- **Required Addition:** RIGHT JOIN support in join.ts and dialect compilation
|
|
45
|
+
|
|
46
|
+
### 4. Complex Aggregation Functions
|
|
47
|
+
|
|
48
|
+
**Missing:** Limited aggregation functions
|
|
49
|
+
|
|
50
|
+
- Queries: `5-top-platform-contributor-per-project`, `5-mega-user-engagement-analytics`
|
|
51
|
+
- **Required Addition:** MIN, MAX, AVG functions, and GROUP_CONCAT
|
|
52
|
+
|
|
53
|
+
### 5. Parameterized Queries
|
|
54
|
+
|
|
55
|
+
**Missing:** No parameter binding support
|
|
56
|
+
|
|
57
|
+
- Queries: `1-1-parameterized-user-by-twitter`
|
|
58
|
+
- **Required Addition:** Parameter placeholder support and binding mechanism
|
|
59
|
+
|
|
60
|
+
### 6. Advanced JSON Operations
|
|
61
|
+
|
|
62
|
+
**Missing:** Limited JSON functionality
|
|
63
|
+
|
|
64
|
+
- Queries: `1-1-profile-json-as-subquery-column`, `5-mega-user-engagement-analytics`
|
|
65
|
+
- **Required Addition:** JSON_OBJECT, JSON_ARRAYAGG functions
|
|
66
|
+
|
|
67
|
+
### 7. Recursive CTEs ✅
|
|
68
|
+
|
|
69
|
+
**Completed:** See Section 1 (CTE) above
|
|
70
|
+
|
|
71
|
+
- Recursive CTEs are fully supported as part of the CTE implementation
|
|
72
|
+
|
|
73
|
+
### 8. Complex Ordering
|
|
74
|
+
|
|
75
|
+
**Missing:** Limited ORDER BY expressions
|
|
76
|
+
|
|
77
|
+
- Queries: `1-1-order-by-subquery-profile-field`, `5-mega-user-engagement-analytics`
|
|
78
|
+
- **Required Addition:** ORDER BY with expressions and NULLS FIRST/LAST
|
|
79
|
+
|
|
80
|
+
### 9. DISTINCT ON (PostgreSQL-style)
|
|
81
|
+
|
|
82
|
+
**Missing:** No DISTINCT ON support
|
|
83
|
+
|
|
84
|
+
- **Required Addition:** Dialect-specific DISTINCT ON compilation
|
|
85
|
+
|
|
86
|
+
### 10. Subquery Aliasing
|
|
87
|
+
|
|
88
|
+
**Missing:** No support for subqueries as derived tables
|
|
89
|
+
|
|
90
|
+
- **Required Addition:** Derived table AST node
|
|
91
|
+
|
|
92
|
+
### 11. Advanced EXISTS Patterns
|
|
93
|
+
|
|
94
|
+
**Missing:** EXISTS with complex correlated subqueries
|
|
95
|
+
|
|
96
|
+
- Queries: `1-1-boolean-flag-from-subquery-on-profile`
|
|
97
|
+
- **Required Addition:** Complex correlation support
|
|
98
|
+
|
|
99
|
+
## Priority Implementation Order
|
|
100
|
+
|
|
101
|
+
### Completed ✅
|
|
102
|
+
|
|
103
|
+
- ~~CTE support~~ (Completed)
|
|
104
|
+
- ~~Window functions~~ (Completed)
|
|
105
|
+
- ~~Recursive CTEs~~ (Completed)
|
|
106
|
+
|
|
107
|
+
### High Priority:
|
|
108
|
+
|
|
109
|
+
- Parameterized queries
|
|
110
|
+
- RIGHT JOIN
|
|
111
|
+
- Complex aggregation functions (MIN, MAX, GROUP_CONCAT)
|
|
112
|
+
|
|
113
|
+
### Medium Priority:
|
|
114
|
+
|
|
115
|
+
- Advanced JSON operations
|
|
116
|
+
- Complex ordering (expressions, NULLS FIRST/LAST)
|
|
117
|
+
|
|
118
|
+
### Lower Priority:
|
|
119
|
+
|
|
120
|
+
- Subquery Aliasing (Derived tables)
|
|
121
|
+
- Advanced EXISTS Patterns
|
|
122
|
+
|
|
123
|
+
## Implementation Notes
|
|
124
|
+
|
|
125
|
+
The current ORM is well-architected with a clear AST structure and dialect abstraction, making it relatively straightforward to add these missing features by extending the existing patterns.
|
package/metadata.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "metal-orm",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./dist/index.mjs",
|
|
8
|
+
"require": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsup",
|
|
13
|
+
"check": "tsc --noEmit",
|
|
14
|
+
"playground": "vite",
|
|
15
|
+
"test": "vitest",
|
|
16
|
+
"test:ui": "vitest --ui"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@mantine/core": "^8.3.9",
|
|
20
|
+
"@mantine/hooks": "^8.3.9",
|
|
21
|
+
"@tabler/icons-react": "^3.35.0",
|
|
22
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
23
|
+
"mysql2": "^3.9.0",
|
|
24
|
+
"postcss": "^8.5.6",
|
|
25
|
+
"postcss-preset-mantine": "^1.18.0",
|
|
26
|
+
"react": "^18.0.0",
|
|
27
|
+
"react-dom": "^18.0.0",
|
|
28
|
+
"react-syntax-highlighter": "^16.1.0",
|
|
29
|
+
"sqlite3": "^5.1.6",
|
|
30
|
+
"tarn": "^3.0.0",
|
|
31
|
+
"tedious": "^16.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^18.0.0",
|
|
35
|
+
"@types/react-dom": "^18.0.0",
|
|
36
|
+
"@types/sqlite3": "^3.1.11",
|
|
37
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
38
|
+
"@vitest/ui": "^4.0.14",
|
|
39
|
+
"tsconfig-paths": "^4.2.0",
|
|
40
|
+
"tsup": "^8.0.0",
|
|
41
|
+
"typescript": "^5.0.0",
|
|
42
|
+
"vite": "^5.0.0",
|
|
43
|
+
"vitest": "^4.0.14"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { SqliteClient } from '../../src/playground/features/playground/clients/SqliteClient';
|
|
3
|
+
import { SCENARIOS } from '../../src/playground/features/playground/data/scenarios';
|
|
4
|
+
import { QueryExecutionService } from '../../src/playground/features/playground/services/QueryExecutionService';
|
|
5
|
+
import type { ApiStatusResponse } from '../../src/playground/features/playground/api/types';
|
|
6
|
+
|
|
7
|
+
export const PLAYGROUND_API_PREFIX = '/api/playground';
|
|
8
|
+
|
|
9
|
+
const sqliteClient = new SqliteClient();
|
|
10
|
+
const queryExecutionService = new QueryExecutionService(sqliteClient);
|
|
11
|
+
|
|
12
|
+
const sendJson = (res: ServerResponse, payload: unknown, status = 200) => {
|
|
13
|
+
res.statusCode = status;
|
|
14
|
+
res.setHeader('Content-Type', 'application/json');
|
|
15
|
+
res.end(JSON.stringify(payload));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const parseJsonBody = (req: IncomingMessage) =>
|
|
19
|
+
new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
20
|
+
const chunks: Buffer[] = [];
|
|
21
|
+
req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
22
|
+
req.on('end', () => {
|
|
23
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
24
|
+
if (!body) {
|
|
25
|
+
resolve({});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
resolve(JSON.parse(body));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
reject(error);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
req.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const handleStatus = (_req: IncomingMessage, res: ServerResponse) => {
|
|
39
|
+
const body: ApiStatusResponse = {
|
|
40
|
+
ready: sqliteClient.isReady,
|
|
41
|
+
error: sqliteClient.error
|
|
42
|
+
};
|
|
43
|
+
sendJson(res, body);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleExecute = async (req: IncomingMessage, res: ServerResponse) => {
|
|
47
|
+
try {
|
|
48
|
+
const { scenarioId } = await parseJsonBody(req);
|
|
49
|
+
if (!scenarioId || typeof scenarioId !== 'string') {
|
|
50
|
+
sendJson(res, { error: 'Missing scenarioId' }, 400);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const scenario = SCENARIOS.find(s => s.id === scenarioId);
|
|
55
|
+
if (!scenario) {
|
|
56
|
+
sendJson(res, { error: `Scenario '${scenarioId}' not found` }, 404);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = await queryExecutionService.executeScenario(scenario);
|
|
61
|
+
sendJson(res, result);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
64
|
+
sendJson(res, { error: message }, 500);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const createPlaygroundApiMiddleware = () => {
|
|
69
|
+
return async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
|
70
|
+
const pathname = req.url ? req.url.split('?')[0] : '';
|
|
71
|
+
|
|
72
|
+
if (req.method === 'GET' && pathname === '/status') {
|
|
73
|
+
handleStatus(req, res);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (req.method === 'POST' && pathname === '/execute') {
|
|
78
|
+
await handleExecute(req, res);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
next();
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const playgroundApiPlugin = () => ({
|
|
87
|
+
name: 'metal-orm-playground-api',
|
|
88
|
+
configureServer(server) {
|
|
89
|
+
server.middlewares.use(PLAYGROUND_API_PREFIX, createPlaygroundApiMiddleware());
|
|
90
|
+
},
|
|
91
|
+
configurePreviewServer(server) {
|
|
92
|
+
server.middlewares.use(PLAYGROUND_API_PREFIX, createPlaygroundApiMiddleware());
|
|
93
|
+
},
|
|
94
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Metal ORM Playground</title>
|
|
8
|
+
</head>
|
|
9
|
+
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
13
|
+
</body>
|
|
14
|
+
|
|
15
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/* App.css is no longer needed as we are using Mantine UI */
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { MantineProvider, AppShell, Burger, Group, Title, Text, ActionIcon, useMantineTheme } from '@mantine/core';
|
|
3
|
+
import { useDisclosure } from '@mantine/hooks';
|
|
4
|
+
import { SCENARIOS, type Scenario } from './data/scenarios';
|
|
5
|
+
import { PlaygroundApiService } from './services/PlaygroundApiService';
|
|
6
|
+
import { ScenarioList } from './components/ScenarioList';
|
|
7
|
+
import { QueryExecutor } from './components/QueryExecutor';
|
|
8
|
+
import '@mantine/core/styles.css';
|
|
9
|
+
import './App.css'; // Keeping for custom overrides if needed, but will likely remove
|
|
10
|
+
|
|
11
|
+
function App() {
|
|
12
|
+
const [selectedScenario, setSelectedScenario] = useState<Scenario | null>(null);
|
|
13
|
+
const [apiReady, setApiReady] = useState(false);
|
|
14
|
+
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
15
|
+
const [opened, { toggle }] = useDisclosure();
|
|
16
|
+
|
|
17
|
+
const queryService = useMemo(() => new PlaygroundApiService(), []);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let isMounted = true;
|
|
21
|
+
let timerId: ReturnType<typeof setTimeout> | null = null;
|
|
22
|
+
|
|
23
|
+
const pollStatus = async () => {
|
|
24
|
+
const status = await queryService.getStatus();
|
|
25
|
+
if (!isMounted) return;
|
|
26
|
+
|
|
27
|
+
if (status.error) {
|
|
28
|
+
setStatusMessage(status.error);
|
|
29
|
+
} else {
|
|
30
|
+
setStatusMessage(null);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (status.ready) {
|
|
34
|
+
setApiReady(true);
|
|
35
|
+
timerId && clearTimeout(timerId);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
timerId = setTimeout(pollStatus, 250);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
pollStatus();
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
isMounted = false;
|
|
46
|
+
if (timerId) {
|
|
47
|
+
clearTimeout(timerId);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}, [queryService]);
|
|
51
|
+
|
|
52
|
+
// Auto-select first scenario when API is ready
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (apiReady && !selectedScenario && SCENARIOS.length > 0) {
|
|
55
|
+
setSelectedScenario(SCENARIOS[0]);
|
|
56
|
+
}
|
|
57
|
+
}, [apiReady, selectedScenario]);
|
|
58
|
+
|
|
59
|
+
const handleScenarioSelect = (scenario: Scenario) => {
|
|
60
|
+
setSelectedScenario(scenario);
|
|
61
|
+
if (window.innerWidth < 768) {
|
|
62
|
+
toggle(); // Close sidebar on mobile selection
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!apiReady) {
|
|
67
|
+
return (
|
|
68
|
+
<MantineProvider defaultColorScheme="dark">
|
|
69
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', flexDirection: 'column' }}>
|
|
70
|
+
<Title order={2}>Initializing Metal ORM Playground</Title>
|
|
71
|
+
<Text c="dimmed">Waiting for the playground API to become ready...</Text>
|
|
72
|
+
{statusMessage && <Text c="red">Error: {statusMessage}</Text>}
|
|
73
|
+
</div>
|
|
74
|
+
</MantineProvider>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<MantineProvider defaultColorScheme="dark">
|
|
80
|
+
<AppShell
|
|
81
|
+
header={{ height: 60 }}
|
|
82
|
+
navbar={{ width: 300, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
|
83
|
+
padding="md"
|
|
84
|
+
>
|
|
85
|
+
<AppShell.Header>
|
|
86
|
+
<Group h="100%" px="md">
|
|
87
|
+
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
|
88
|
+
<Group justify="space-between" style={{ flex: 1 }}>
|
|
89
|
+
<Title order={3}>⚡ Metal ORM Playground</Title>
|
|
90
|
+
<Text size="sm" c="dimmed" visibleFrom="sm">Explore and test ORM query scenarios</Text>
|
|
91
|
+
</Group>
|
|
92
|
+
</Group>
|
|
93
|
+
</AppShell.Header>
|
|
94
|
+
|
|
95
|
+
<AppShell.Navbar p="md">
|
|
96
|
+
<ScenarioList
|
|
97
|
+
scenarios={SCENARIOS}
|
|
98
|
+
selectedId={selectedScenario?.id || null}
|
|
99
|
+
onSelect={handleScenarioSelect}
|
|
100
|
+
/>
|
|
101
|
+
</AppShell.Navbar>
|
|
102
|
+
|
|
103
|
+
<AppShell.Main>
|
|
104
|
+
<QueryExecutor
|
|
105
|
+
scenario={selectedScenario}
|
|
106
|
+
queryService={queryService}
|
|
107
|
+
/>
|
|
108
|
+
</AppShell.Main>
|
|
109
|
+
</AppShell>
|
|
110
|
+
</MantineProvider>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default App;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
3
|
+
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
4
|
+
import { ScrollArea } from '@mantine/core';
|
|
5
|
+
|
|
6
|
+
interface CodeDisplayProps {
|
|
7
|
+
code: string;
|
|
8
|
+
language?: 'sql' | 'typescript';
|
|
9
|
+
title?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Component responsible for displaying syntax-highlighted code
|
|
14
|
+
* Follows SRP by handling only code display formatting
|
|
15
|
+
*/
|
|
16
|
+
export const CodeDisplay: React.FC<CodeDisplayProps> = ({
|
|
17
|
+
code,
|
|
18
|
+
language = 'sql',
|
|
19
|
+
title
|
|
20
|
+
}) => {
|
|
21
|
+
return (
|
|
22
|
+
<ScrollArea bg="#282c34">
|
|
23
|
+
<SyntaxHighlighter
|
|
24
|
+
language={language}
|
|
25
|
+
style={oneDark}
|
|
26
|
+
customStyle={{
|
|
27
|
+
margin: 0,
|
|
28
|
+
padding: '1.25rem',
|
|
29
|
+
background: 'transparent',
|
|
30
|
+
fontSize: '0.9rem',
|
|
31
|
+
lineHeight: '1.6'
|
|
32
|
+
}}
|
|
33
|
+
codeTagProps={{
|
|
34
|
+
style: {
|
|
35
|
+
fontFamily: 'JetBrains Mono, Fira Code, monospace'
|
|
36
|
+
}
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{code}
|
|
40
|
+
</SyntaxHighlighter>
|
|
41
|
+
</ScrollArea>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Card, Button, Title, Text, Tabs, Loader, Group, Stack, Badge, ActionIcon, CopyButton, Tooltip } from '@mantine/core';
|
|
3
|
+
import { IconPlayerPlay, IconCode, IconDatabase, IconCheck, IconCopy } from '@tabler/icons-react'; // Assuming tabler icons are available or we use text
|
|
4
|
+
import type { Scenario } from '../data/scenarios';
|
|
5
|
+
import type { QueryExecutionResult } from '@orm/playground/features/playground/api/types';
|
|
6
|
+
import { CodeDisplay } from './CodeDisplay';
|
|
7
|
+
import { ResultsTabs } from './ResultsTabs';
|
|
8
|
+
import { PlaygroundApiService } from '../services/PlaygroundApiService';
|
|
9
|
+
|
|
10
|
+
const describeBinding = (value: unknown): { display: string; type: string } => {
|
|
11
|
+
if (value === null) {
|
|
12
|
+
return { display: 'null', type: 'null' };
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === 'undefined') {
|
|
15
|
+
return { display: 'undefined', type: 'undefined' };
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === 'string') {
|
|
18
|
+
return { display: `"${value}"`, type: 'string' };
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === 'number') {
|
|
21
|
+
return { display: value.toString(), type: Number.isInteger(value) ? 'integer' : 'number' };
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === 'boolean') {
|
|
24
|
+
return { display: value ? 'true' : 'false', type: 'boolean' };
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return { display: JSON.stringify(value), type: 'array' };
|
|
28
|
+
}
|
|
29
|
+
return { display: JSON.stringify(value), type: typeof value };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const BindingsDisplay: React.FC<{ params: unknown[] }> = ({ params }) => {
|
|
33
|
+
const hasParams = Array.isArray(params) && params.length > 0;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
style={{
|
|
38
|
+
border: '1px solid var(--mantine-color-dark-5)',
|
|
39
|
+
borderRadius: 'var(--mantine-radius-md)',
|
|
40
|
+
padding: 'var(--mantine-spacing-md)',
|
|
41
|
+
background: 'var(--mantine-color-dark-8)'
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<Group justify="space-between" mb="sm">
|
|
45
|
+
<Text fw={500}>Bindings</Text>
|
|
46
|
+
{hasParams && (
|
|
47
|
+
<CopyButton value={JSON.stringify(params, null, 2)}>
|
|
48
|
+
{({ copied, copy }) => (
|
|
49
|
+
<Tooltip label={copied ? 'Copied' : 'Copy JSON'} withArrow>
|
|
50
|
+
<ActionIcon variant="subtle" color={copied ? 'teal' : 'blue'} onClick={copy}>
|
|
51
|
+
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
|
52
|
+
</ActionIcon>
|
|
53
|
+
</Tooltip>
|
|
54
|
+
)}
|
|
55
|
+
</CopyButton>
|
|
56
|
+
)}
|
|
57
|
+
</Group>
|
|
58
|
+
{!hasParams && <Text c="dimmed" fz="sm">Query has no parameter bindings.</Text>}
|
|
59
|
+
{hasParams && (
|
|
60
|
+
<Stack gap="xs">
|
|
61
|
+
{params.map((value, index) => {
|
|
62
|
+
const { display, type } = describeBinding(value);
|
|
63
|
+
return (
|
|
64
|
+
<Group
|
|
65
|
+
key={`${index}-${String(value)}`}
|
|
66
|
+
justify="space-between"
|
|
67
|
+
style={{
|
|
68
|
+
border: '1px solid var(--mantine-color-dark-5)',
|
|
69
|
+
borderRadius: 'var(--mantine-radius-sm)',
|
|
70
|
+
padding: 'var(--mantine-spacing-xs)'
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<Badge variant="light" color="grape">#{index + 1}</Badge>
|
|
74
|
+
<div style={{ textAlign: 'right' }}>
|
|
75
|
+
<Text style={{ fontFamily: 'var(--mantine-font-family-monospace)' }}>
|
|
76
|
+
{display}
|
|
77
|
+
</Text>
|
|
78
|
+
<Text fz="xs" c="dimmed">
|
|
79
|
+
{type.toUpperCase()}
|
|
80
|
+
</Text>
|
|
81
|
+
</div>
|
|
82
|
+
</Group>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</Stack>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
interface QueryExecutorProps {
|
|
92
|
+
scenario: Scenario | null;
|
|
93
|
+
queryService: PlaygroundApiService;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Component responsible for executing queries and displaying results
|
|
98
|
+
* Follows SRP by coordinating query execution and result display
|
|
99
|
+
*/
|
|
100
|
+
export const QueryExecutor: React.FC<QueryExecutorProps> = ({
|
|
101
|
+
scenario,
|
|
102
|
+
queryService
|
|
103
|
+
}) => {
|
|
104
|
+
const [result, setResult] = useState<QueryExecutionResult | null>(null);
|
|
105
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (scenario) {
|
|
109
|
+
executeQuery();
|
|
110
|
+
}
|
|
111
|
+
}, [scenario]);
|
|
112
|
+
|
|
113
|
+
const executeQuery = async () => {
|
|
114
|
+
if (!scenario) return;
|
|
115
|
+
|
|
116
|
+
setIsLoading(true);
|
|
117
|
+
const executionResult = await queryService.executeScenario(scenario.id);
|
|
118
|
+
setResult(executionResult);
|
|
119
|
+
setIsLoading(false);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!scenario) {
|
|
123
|
+
return (
|
|
124
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--mantine-color-dimmed)' }}>
|
|
125
|
+
<Text>Select a scenario from the list to execute</Text>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Stack gap="lg">
|
|
132
|
+
<div>
|
|
133
|
+
<Group justify="space-between" align="start" mb="md">
|
|
134
|
+
<div>
|
|
135
|
+
<Title order={2}>{scenario.title}</Title>
|
|
136
|
+
<Text c="dimmed" mt="xs">{scenario.description}</Text>
|
|
137
|
+
</div>
|
|
138
|
+
<Button
|
|
139
|
+
onClick={executeQuery}
|
|
140
|
+
loading={isLoading}
|
|
141
|
+
leftSection={<span>▶</span>}
|
|
142
|
+
variant="gradient"
|
|
143
|
+
gradient={{ from: 'indigo', to: 'cyan' }}
|
|
144
|
+
>
|
|
145
|
+
Re-execute Query
|
|
146
|
+
</Button>
|
|
147
|
+
</Group>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{result && (
|
|
151
|
+
<>
|
|
152
|
+
<Card withBorder shadow="sm" radius="md" p={0}>
|
|
153
|
+
<Tabs defaultValue="sql">
|
|
154
|
+
<Tabs.List>
|
|
155
|
+
<Tabs.Tab value="sql" leftSection={<span>SQL</span>}>Generated SQL</Tabs.Tab>
|
|
156
|
+
<Tabs.Tab value="typescript" leftSection={<span>TS</span>}>TypeScript</Tabs.Tab>
|
|
157
|
+
</Tabs.List>
|
|
158
|
+
|
|
159
|
+
<Tabs.Panel value="sql">
|
|
160
|
+
<Stack gap="sm" p="md">
|
|
161
|
+
<CodeDisplay code={result.sql} language="sql" />
|
|
162
|
+
<BindingsDisplay params={result.params} />
|
|
163
|
+
</Stack>
|
|
164
|
+
</Tabs.Panel>
|
|
165
|
+
|
|
166
|
+
<Tabs.Panel value="typescript">
|
|
167
|
+
<CodeDisplay code={result.typescriptCode} language="typescript" />
|
|
168
|
+
</Tabs.Panel>
|
|
169
|
+
</Tabs>
|
|
170
|
+
</Card>
|
|
171
|
+
|
|
172
|
+
<ResultsTabs
|
|
173
|
+
results={result.results}
|
|
174
|
+
hydratedResults={result.hydratedResults}
|
|
175
|
+
executionTime={result.executionTime}
|
|
176
|
+
error={result.error}
|
|
177
|
+
/>
|
|
178
|
+
</>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{isLoading && (
|
|
182
|
+
<Group justify="center" p="xl">
|
|
183
|
+
<Loader type="dots" />
|
|
184
|
+
<Text>Executing query...</Text>
|
|
185
|
+
</Group>
|
|
186
|
+
)}
|
|
187
|
+
</Stack>
|
|
188
|
+
);
|
|
189
|
+
};
|