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
56 changes: 50 additions & 6 deletions services/sql-assessment-service/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,50 @@
}
}
},
"PGliteConnectionInfo": {
"type": "object",
"description": "Connection info for an in-process PGlite database. The instance is registered out-of-band (via /analyze-database or a configured init-SQL file) and referenced afterwards by databaseId.\n",
"required": [
"type",
"databaseId"
],
"properties": {
"type": {
"type": "string",
"enum": [
"pglite"
],
"example": "pglite"
},
"databaseId": {
"type": "string",
"description": "Stable identifier of a registered PGlite instance.",
"example": "my-database"
},
"sqlContent": {
"type": "string",
"description": "SQL used to (re-)initialise the instance. Required by /analyze-database; optional on downstream calls, which reuse the already-registered instance.\n"
}
}
},
"ConnectionInfo": {
"description": "Backend-agnostic connection info. Discriminated on the `type` field: `postgres` selects PostgresConnectionInfo, `pglite` selects PGliteConnectionInfo.\n",
"oneOf": [
{
"$ref": "#/components/schemas/PostgresConnectionInfo"
},
{
"$ref": "#/components/schemas/PGliteConnectionInfo"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"postgres": "#/components/schemas/PostgresConnectionInfo",
"pglite": "#/components/schemas/PGliteConnectionInfo"
}
}
},
"AliasMap": {
"type": "object",
"description": "Optional human-readable display names for tables and columns. Supplied at database analysis time; used in generated descriptions.\n",
Expand Down Expand Up @@ -757,7 +801,7 @@
],
"properties": {
"connectionInfo": {
"$ref": "#/components/schemas/PostgresConnectionInfo"
"$ref": "#/components/schemas/ConnectionInfo"
},
"aliasMap": {
"$ref": "#/components/schemas/AliasMap"
Expand Down Expand Up @@ -845,7 +889,7 @@
],
"properties": {
"connectionInfo": {
"$ref": "#/components/schemas/PostgresConnectionInfo"
"$ref": "#/components/schemas/ConnectionInfo"
},
"taskConfiguration": {
"$ref": "#/components/schemas/TaskConfiguration"
Expand Down Expand Up @@ -901,7 +945,7 @@
],
"properties": {
"connectionInfo": {
"$ref": "#/components/schemas/PostgresConnectionInfo"
"$ref": "#/components/schemas/ConnectionInfo"
},
"query": {
"type": "string",
Expand Down Expand Up @@ -971,7 +1015,7 @@
],
"properties": {
"connectionInfo": {
"$ref": "#/components/schemas/PostgresConnectionInfo"
"$ref": "#/components/schemas/ConnectionInfo"
},
"gradingRequest": {
"type": "object",
Expand Down Expand Up @@ -1059,7 +1103,7 @@
],
"properties": {
"connectionInfo": {
"$ref": "#/components/schemas/PostgresConnectionInfo"
"$ref": "#/components/schemas/ConnectionInfo"
},
"referenceQuery": {
"type": "string",
Expand Down Expand Up @@ -1147,7 +1191,7 @@
],
"properties": {
"connectionInfo": {
"$ref": "#/components/schemas/PostgresConnectionInfo"
"$ref": "#/components/schemas/ConnectionInfo"
},
"query": {
"type": "string",
Expand Down
93 changes: 93 additions & 0 deletions services/sql-assessment-service/scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
#
# PGlite path (no external DB needed):
# PGLITE_DB_ID databaseId to use (default: smoke-db)
# (also covers the grading endpoints — /api/grading/grade and
# /api/grading/compare/* now run against the in-process PGlite backend)
#
# Init-SQL-file feature (optional — tests the PGLITE_INIT_SQL_FILE / --init-sql-file feature):
# SMOKE_TEST_INIT_SQL Set to '1' to enable (server must be started with PGLITE_INIT_SQL_FILE
Expand Down Expand Up @@ -233,6 +235,96 @@ INSERT INTO orders (product_id, quantity) VALUES (1, 5), (2, 3);'
'{connectionInfo:{type:"pglite",databaseId:$id},query:"SELECT * FROM categories"}')"
}

# ---- PGlite grading tests --------------------------------------------------
# Grading endpoints now run against the in-process PGlite backend (no external
# PostgreSQL required). These exercise /api/grading/grade and the three
# /api/grading/compare/* endpoints end-to-end.

pglite_grading_tests() {
echo ""
echo "── PGlite grading path (${BASE_URL}) ──────────────────────────────────"

local GRADE_DB_ID="smoke-grading-db"
local DDL
DDL='CREATE TABLE products (id SERIAL PRIMARY KEY, name TEXT NOT NULL, price NUMERIC(10,2));
INSERT INTO products (name, price) VALUES ('\''Widget'\'', 9.99), ('\''Gadget'\'', 19.99), ('\''Gizmo'\'', 29.99);'

local REF='SELECT name FROM products WHERE price > 15 ORDER BY name'
local CORRECT='SELECT name FROM products WHERE price > 15 ORDER BY name'
local WRONG='SELECT name FROM products ORDER BY name'

# 1. Register the grading DB
post "grading — analyze-database (seed DDL)" "200" \
"${BASE_URL}/api/database/analyze-database" \
"$(jq -n --arg id "$GRADE_DB_ID" --arg sql "$DDL" \
'{connectionInfo:{type:"pglite",databaseId:$id,sqlContent:$sql}}')"

# 2. Full grade — identical query → equivalent:true, grade 7
post_body_contains "grading — grade identical query (equivalent)" "200" \
"${BASE_URL}/api/grading/grade" \
"$(jq -n --arg id "$GRADE_DB_ID" --arg ref "$REF" --arg stu "$CORRECT" \
'{connectionInfo:{type:"pglite",databaseId:$id},gradingRequest:{referenceQuery:$ref,studentQuery:$stu}}')" \
'"equivalent":true'

# 3. Full grade — wrong query → not equivalent
post_body_contains "grading — grade wrong query (not equivalent)" "200" \
"${BASE_URL}/api/grading/grade" \
"$(jq -n --arg id "$GRADE_DB_ID" --arg ref "$REF" --arg stu "$WRONG" \
'{connectionInfo:{type:"pglite",databaseId:$id},gradingRequest:{referenceQuery:$ref,studentQuery:$stu}}')" \
'"equivalent":false'

# 4. compare/result-set — matching sets → match:true
post_body_contains "grading — compare/result-set (match)" "200" \
"${BASE_URL}/api/grading/compare/result-set" \
"$(jq -n --arg id "$GRADE_DB_ID" --arg ref "$REF" --arg stu "$CORRECT" \
'{connectionInfo:{type:"pglite",databaseId:$id},referenceQuery:$ref,studentQuery:$stu}')" \
'"match":true'

# 5. compare/result-set — differing sets → match:false
post_body_contains "grading — compare/result-set (mismatch)" "200" \
"${BASE_URL}/api/grading/compare/result-set" \
"$(jq -n --arg id "$GRADE_DB_ID" --arg ref "$REF" --arg stu "$WRONG" \
'{connectionInfo:{type:"pglite",databaseId:$id},referenceQuery:$ref,studentQuery:$stu}')" \
'"match":false'

# 6. compare/ast — same column list → columnsMatch:true
post_body_contains "grading — compare/ast (columns match)" "200" \
"${BASE_URL}/api/grading/compare/ast" \
"$(jq -n --arg id "$GRADE_DB_ID" --arg ref "$REF" --arg stu "$CORRECT" \
'{connectionInfo:{type:"pglite",databaseId:$id},referenceQuery:$ref,studentQuery:$stu}')" \
'"columnsMatch":true'

# 7. compare/execution-plan — identical plans → plansMatch:true (proves EXPLAIN runs on PGlite)
post_body_contains "grading — compare/execution-plan (plans match)" "200" \
"${BASE_URL}/api/grading/compare/execution-plan" \
"$(jq -n --arg id "$GRADE_DB_ID" --arg ref "$REF" --arg stu "$CORRECT" \
'{connectionInfo:{type:"pglite",databaseId:$id},referenceQuery:$ref,studentQuery:$stu}')" \
'"plansMatch":true'

# 8. compare/execution-plan — differing WHERE → plansMatch:false
post_body_contains "grading — compare/execution-plan (plans differ)" "200" \
"${BASE_URL}/api/grading/compare/execution-plan" \
"$(jq -n --arg id "$GRADE_DB_ID" --arg ref "$REF" --arg stu "$WRONG" \
'{connectionInfo:{type:"pglite",databaseId:$id},referenceQuery:$ref,studentQuery:$stu}')" \
'"plansMatch":false'

# 9. The shared PGlite instance survives grading — a follow-up query still works
post "grading — DB still usable after grading (shared instance not closed)" "200" \
"${BASE_URL}/api/query/execute" \
"$(jq -n --arg id "$GRADE_DB_ID" \
'{connectionInfo:{type:"pglite",databaseId:$id},query:"SELECT COUNT(*) AS c FROM products"}')"

# 10. grade against an unregistered databaseId → 400 (only meaningful without init file)
if [[ -z "${SMOKE_TEST_INIT_SQL:-}" ]]; then
post "grading — grade unregistered databaseId (expect 400)" "400" \
"${BASE_URL}/api/grading/grade" \
"$(jq -n --arg ref "$REF" --arg stu "$CORRECT" \
'{connectionInfo:{type:"pglite",databaseId:"__not_registered_grading__"},gradingRequest:{referenceQuery:$ref,studentQuery:$stu}}')"
else
green " SKIP grading — unregistered databaseId (init-SQL-file auto-initialises any databaseId)"
fi
}

# ---- Init-SQL-file tests (optional) ---------------------------------------

pglite_init_sql_file_tests() {
Expand Down Expand Up @@ -460,6 +552,7 @@ done
echo " OK"

pglite_tests
pglite_grading_tests
pglite_init_sql_file_tests
postgres_tests
cli_tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,12 +308,12 @@ export class DescriptionController {
// ---------------------------------------------------------------------

// ---- PGlite branch --------------------------------------------------
const connectionInfoAny = options.connectionInfo as any;
const connectionInfo = options.connectionInfo;
let databaseKey: string;
let schema: string;

if (connectionInfoAny?.type === 'pglite') {
const { databaseId } = connectionInfoAny;
if (connectionInfo.type === 'pglite') {
const { databaseId } = connectionInfo;
if (!databaseId || typeof databaseId !== 'string') {
res.status(400).json({ message: t('INVALID_CONNECTION_INFO', lang) });
return null;
Expand All @@ -322,16 +322,13 @@ export class DescriptionController {
schema = 'public';
} else {
// PostgreSQL path
const connectionError = validateConnectionInfo(
options.connectionInfo,
lang,
);
const connectionError = validateConnectionInfo(connectionInfo, lang);
if (connectionError) {
res.status(400).json({ message: connectionError });
return null;
}

const { host, port, schema: pgSchema } = options.connectionInfo;
const { host, port, schema: pgSchema } = connectionInfo;
databaseKey = generateDatabaseKey(host!, port!, pgSchema!);
schema = pgSchema!;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express';
import { DataSource } from 'typeorm';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
import {
ConnectionInfo,
GenerationOptions,
GptOptions,
IRequestTaskOptions,
Expand Down Expand Up @@ -95,7 +95,7 @@ export class TaskGenerationController {
res: Response,
): Promise<Response> {
let taskRequest: IRequestTaskOptions;
let connectionInfo: PostgresConnectionOptions;
let connectionInfo: ConnectionInfo;

try {
taskRequest = req.body;
Expand Down Expand Up @@ -131,9 +131,8 @@ export class TaskGenerationController {
// ---------------------------------------------------------------------

// ---- PGlite branch --------------------------------------------------
const connectionInfoAny = connectionInfo as any;
if (connectionInfoAny?.type === 'pglite') {
const { databaseId } = connectionInfoAny;
if (connectionInfo.type === 'pglite') {
const { databaseId } = connectionInfo;
if (!databaseId || typeof databaseId !== 'string') {
return res
.status(400)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DataSource } from 'typeorm';
import { AST, Join } from 'node-sql-parser';
import { RowQueryFn } from '../../shared/utils/database-utils';
import {
IParsedExecutionPlan,
ParsedSubplan,
Expand Down Expand Up @@ -60,27 +60,19 @@ export class ExecutionPlanComparator {
referenceAST: AST,
studentAliasMap: Record<string, string>,
referenceAliasMap: Record<string, string>,
dataSource: DataSource,
runQuery: RowQueryFn,
studentQuery: string,
referenceQuery: string,
lang: SupportedLanguage = 'en',
): Promise<ExecutionPlanComparisonResult> {
const executionPlan: ExecutionPlanFeedback = {};

const queryRunner = dataSource.createQueryRunner();
let studentRawPlan: any;
let referenceRawPlan: any;

try {
studentRawPlan = await queryRunner.query(
`EXPLAIN (FORMAT JSON) ${studentQuery}`,
);
referenceRawPlan = await queryRunner.query(
`EXPLAIN (FORMAT JSON) ${referenceQuery}`,
);
} finally {
await queryRunner.release();
}
const studentRawPlan = await runQuery(
`EXPLAIN (FORMAT JSON) ${studentQuery}`,
);
const referenceRawPlan = await runQuery(
`EXPLAIN (FORMAT JSON) ${referenceQuery}`,
);

if (!studentRawPlan || !referenceRawPlan) {
executionPlan.planRetrieval = {
Expand Down
Loading
Loading