diff --git a/CLAUDE.md b/CLAUDE.md index 32cb45f..dd9c1d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ apso dev # Start local dev server via Docker Compose apso migrate # Test schema migrations locally (PGlite sandbox) apso migrate generate # Migration from a deployed .apsorc to this project's entities (TypeScript, PGlite) apso migrate verify # Run a migration on the deployed schema, then check for drift -apso test smoke # Start the service on PGlite; call /health and every list endpoint +apso test smoke # Start the service; call /health, list, and create/read/delete each entity (TS on PGlite, Python/Go on DATABASE_URL) apso schema validate # Validate local .apsorc (includes lint) apso schema lint # Lint .apsorc with @apso/schema-tools (--fix, --json) apso schema diff # Diff local vs remote schema diff --git a/scripts/scaffold-smoke.sh b/scripts/scaffold-smoke.sh index d7be914..bac0b09 100755 --- a/scripts/scaffold-smoke.sh +++ b/scripts/scaffold-smoke.sh @@ -14,6 +14,7 @@ # Exit non-zero on the first failure so CI surfaces a broken scaffold. # Run locally: bash scripts/scaffold-smoke.sh [typescript|python|go] # Optional: APSORC_FIXTURE=test/fixtures/.apsorc to generate from a fixture. +# Optional (go): SMOKE_DATABASE_URL= to also run `apso test smoke`. set -euo pipefail LANGUAGE="${1:-typescript}" @@ -103,6 +104,13 @@ case "$LANGUAGE" in "$(go env GOPATH)/bin/swag" init -g cmd/main.go -o docs echo "==> Compiling the scaffolded project" go build ./... + # Go services need a real Postgres. Opt in with an empty database, e.g. + # SMOKE_DATABASE_URL=postgres://me@localhost:5432/apso_smoke?sslmode=disable + # (DATABASE_SYNC lets the service create its tables there). + if [ -n "${SMOKE_DATABASE_URL:-}" ]; then + echo "==> Smoke testing the endpoints on SMOKE_DATABASE_URL" + DATABASE_URL="$SMOKE_DATABASE_URL" DATABASE_SYNC=true "$CLI_ROOT/bin/run" test smoke + fi ;; python) # The template requires Python >= 3.11; override with PYTHON=python3.12 diff --git a/src/commands/migrate/generate.ts b/src/commands/migrate/generate.ts index ab17555..dcb0426 100644 --- a/src/commands/migrate/generate.ts +++ b/src/commands/migrate/generate.ts @@ -1,7 +1,13 @@ import * as path from "path"; import { Flags } from "@oclif/core"; import BaseCommand from "../../lib/base-command"; -import { cliBinPath, generateMigration, report } from "../../lib/verify"; +import { parseApsorc } from "../../lib/apsorc-parser"; +import { + cliBinPath, + migrateUnsupported, + generateMigration, + report, +} from "../../lib/verify"; export default class MigrateGenerate extends BaseCommand { static description = @@ -26,6 +32,8 @@ export default class MigrateGenerate extends BaseCommand { async run(): Promise { const { flags } = await this.parse(MigrateGenerate); + const unsupported = migrateUnsupported(parseApsorc().language); + if (unsupported) this.error(unsupported); const result = await generateMigration( process.cwd(), path.resolve(flags.baseline), diff --git a/src/commands/migrate/verify.ts b/src/commands/migrate/verify.ts index 6bdd2d2..39a62b4 100644 --- a/src/commands/migrate/verify.ts +++ b/src/commands/migrate/verify.ts @@ -1,7 +1,13 @@ import * as path from "path"; import { Flags } from "@oclif/core"; import BaseCommand from "../../lib/base-command"; -import { cliBinPath, report, verifyMigration } from "../../lib/verify"; +import { parseApsorc } from "../../lib/apsorc-parser"; +import { + cliBinPath, + migrateUnsupported, + report, + verifyMigration, +} from "../../lib/verify"; export default class MigrateVerify extends BaseCommand { static description = @@ -32,6 +38,8 @@ export default class MigrateVerify extends BaseCommand { async run(): Promise { const { flags } = await this.parse(MigrateVerify); + const unsupported = migrateUnsupported(parseApsorc().language); + if (unsupported) this.error(unsupported); const result = await verifyMigration( process.cwd(), path.resolve(flags.baseline), diff --git a/src/commands/test/smoke.ts b/src/commands/test/smoke.ts index f191a1a..abd010e 100644 --- a/src/commands/test/smoke.ts +++ b/src/commands/test/smoke.ts @@ -5,13 +5,14 @@ import { report, smokeTest } from "../../lib/verify"; export default class TestSmoke extends BaseCommand { static description = - "Start the service on a local PGlite database and call /health and every entity's list endpoint (TypeScript services)"; + "Start the service and call /health, every entity's list endpoint, and a create/read/delete of one row per entity. TypeScript services run on a local PGlite database; Python and Go services run on the database in DATABASE_URL (rows are created and deleted there, tables are not created) and the check is skipped when none answers"; static examples = [`$ apso test smoke`]; static flags = { "skip-build": Flags.boolean({ - description: "Use the existing dist/ build instead of building first", + description: + "Use the existing dist/ build instead of building first (TypeScript)", default: false, }), json: Flags.boolean({ @@ -22,17 +23,23 @@ export default class TestSmoke extends BaseCommand { async run(): Promise { const { flags } = await this.parse(TestSmoke); - const { entities, auth } = parseApsorc(); + const { entities, relationshipMap, auth, language } = parseApsorc(); // With auth configured every entity route needs a session, so only /health is checked. - const names = auth ? [] : entities.map((e) => e.name); - const result = await smokeTest(process.cwd(), names, { - skipBuild: flags["skip-build"], - }); + const result = await smokeTest( + process.cwd(), + auth ? [] : entities, + relationshipMap, + { skipBuild: flags["skip-build"], language } + ); + const summary = + result.steps[0]?.name === "skipped" + ? "Smoke test skipped." + : "Smoke test passed."; report( (s) => this.log(s), result, flags.json, - result.ok ? "Smoke test passed." : undefined + result.ok ? summary : undefined ); if (!result.ok) this.exit(1); } diff --git a/src/lib/generators/go.ts b/src/lib/generators/go.ts index 0b59ece..20d32f4 100644 --- a/src/lib/generators/go.ts +++ b/src/lib/generators/go.ts @@ -206,6 +206,9 @@ export class GoGenerator extends BaseGenerator { createPrimaryKey, primaryKeyType, snakeCasedName: snakeCase(name), + // Honor an explicit per-entity `table` override; otherwise derive from + // the entity name. See apsoai/cli#107 (Python/Go parity with #98). + tableName: entity.table || snakeCase(name), createdAt, updatedAt, pluralizedName: camelCase(pluralize(name)), diff --git a/src/lib/generators/python.ts b/src/lib/generators/python.ts index 5297cb2..2f92a9e 100644 --- a/src/lib/generators/python.ts +++ b/src/lib/generators/python.ts @@ -142,6 +142,13 @@ export class PythonGenerator extends BaseGenerator { createPrimaryKey, primaryKeyType, snakeCasedName: snakeCase(name), + // Honor an explicit per-entity `table` override; otherwise derive from + // the entity name. See apsoai/cli#107 (Python/Go parity with #98). + tableName: entity.table || snakeCase(name), + // Foreign keys name the referenced entity's real table, override included. + tableNames: Object.fromEntries( + allEntities.map((e) => [e.name, e.table || snakeCase(e.name)]) + ), createdAt, updatedAt, pluralizedName: camelCase(pluralize(name)), diff --git a/src/lib/templates/go/models/model.eta b/src/lib/templates/go/models/model.eta index 1cb619d..9e677e7 100644 --- a/src/lib/templates/go/models/model.eta +++ b/src/lib/templates/go/models/model.eta @@ -9,7 +9,7 @@ import ( <% } %> ) -// <%= it.name %> represents the <%= it.snakeCasedName %> table +// <%= it.name %> represents the <%= it.tableName %> table type <%= it.name %> struct { <% if (it.createPrimaryKey) { %> <% if (it.primaryKeyType === 'uuid') { %> @@ -48,5 +48,5 @@ type <%= it.name %> struct { // TableName overrides the table name func (<%= it.name %>) TableName() string { - return "<%= it.snakeCasedName %>" + return "<%= it.tableName %>" } diff --git a/src/lib/templates/python/models/model.eta b/src/lib/templates/python/models/model.eta index 24a8753..9191325 100644 --- a/src/lib/templates/python/models/model.eta +++ b/src/lib/templates/python/models/model.eta @@ -25,11 +25,11 @@ if TYPE_CHECKING: <% /* Generate ManyToMany junction tables */ %> <% it.associations.filter(a => a.type === 'ManyToMany' && a.joinTable).forEach((assoc) => { %> -<%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %> = Table( - "<%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %>", +<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %> = Table( + "<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>", Base.metadata, - Column("<%= assoc.joinColumnName || `${it.snakeCasedName}_id` %>", <% if (it.primaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.snakeCasedName %>.id"), primary_key=True), - Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= assoc.name.toLowerCase() %>.id"), primary_key=True), + Column("<%= assoc.joinColumnName || `${it.tableName}_id` %>", <% if (it.primaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableName %>.id"), primary_key=True), + Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableNames[assoc.name] || assoc.name.toLowerCase() %>.id"), primary_key=True), ) <% }) %> @@ -37,13 +37,13 @@ class <%= it.name %>(Base): """ SQLAlchemy model for <%= it.name %> """ - __tablename__ = "<%= it.snakeCasedName %>" + __tablename__ = "<%= it.tableName %>" <% /* Generate table arguments for indexes and unique constraints */ %> <% if ((it.indexes && it.indexes.length > 0) || (it.uniques && it.uniques.length > 0)) { %> __table_args__ = ( <% it.indexes.forEach((index) => { %> - Index("<%= index.name || `idx_${it.snakeCasedName}_${index.fields.join('_')}` %>", <%~ index.fields.map(f => `"${f}"`).join(', ') %><% if (index.unique) { %>, unique=True<% } %>), + Index("<%= index.name || `idx_${it.tableName}_${index.fields.join('_')}` %>", <%~ index.fields.map(f => `"${f}"`).join(', ') %><% if (index.unique) { %>, unique=True<% } %>), <% }) %> <% it.uniques.forEach((unique) => { %> UniqueConstraint(<%~ unique.fields.map(f => `"${f}"`).join(', ') %><% if (unique.name) { %>, name="<%= unique.name %>"<% } %>), @@ -93,7 +93,7 @@ columnsToProcess.forEach((field) => { -%> # Foreign key for <%= assoc.name %> <%= assoc.camelCasedId %>: Mapped[<% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>Optional[UUID]<% } else { %>Optional[int]<% } %>] = mapped_column( <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, - ForeignKey("<%= assoc.name.toLowerCase() %>.id"<% if (assoc.cascadeDelete) { %>, ondelete="CASCADE"<% } %>), + ForeignKey("<%= it.tableNames[assoc.name] || assoc.name.toLowerCase() %>.id"<% if (assoc.cascadeDelete) { %>, ondelete="CASCADE"<% } %>), nullable=<%= assoc.nullable ? 'True' : 'False' %><% if (assoc.index) { %>, index=True<% } %> ) @@ -105,7 +105,7 @@ columnsToProcess.forEach((field) => { -%> <% } else if (assoc.type === 'ManyToMany') { %> <%= assoc.pluralizedRelationshipName %>: Mapped[List["<%= assoc.name %>"]] = relationship( "<%= assoc.name %>", - secondary=<%= assoc.joinTableName || `${it.snakeCasedName}_${assoc.name.toLowerCase()}` %>, + secondary=<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>, <% if (assoc.biDirectional) { %>back_populates="<%= assoc.inverseSidePropertyName %>",<% } %> lazy="selectin" ) diff --git a/src/lib/verify/crud-plan.ts b/src/lib/verify/crud-plan.ts new file mode 100644 index 0000000..d1fbf74 --- /dev/null +++ b/src/lib/verify/crud-plan.ts @@ -0,0 +1,124 @@ +/** + * What the smoke test creates, reads and deletes: one row per entity, built + * from the .apsorc, in an order where every required parent exists first. + */ + +import { randomUUID } from "crypto"; +import { Entity } from "../types/entity"; +import { Field } from "../types/field"; +import { RelationshipMap } from "../types/relationship"; +import { getRelationshipIdField } from "../utils/relationships"; + +const DATE = "2024-01-01T00:00:00.000Z"; + +/** A value for a required field, or undefined when its type can't be filled. */ +export function sampleValue(field: Field): unknown { + if (field.is_email) return "smoke@example.com"; + switch (field.type as string) { + case "text": + case "string": + case "varchar": + return "smoke".slice(0, Math.min(5, field.length ?? 5)); + case "integer": + case "int": + case "bigint": + return 1; + case "decimal": + case "numeric": + case "float": + return 1.5; + case "boolean": + return true; + case "date": + case "timestamp": + case "timestamptz": + case "datetime": + return DATE; + case "enum": + return field.values?.[0]; + case "uuid": + return randomUUID(); + case "json": + case "jsonb": + case "json-plain": + return {}; + default: + return undefined; + } +} + +/** Required = not nullable, no default, and not a user-declared primary key. */ +const required = (field: Field) => + !field.nullable && + (field.default === undefined || field.default === null) && + !field.primary; + +export interface PlannedEntity { + name: string; + payload: Record; + /** Foreign keys to fill from parents created earlier: property -> parent entity. */ + parents: { property: string; entity: string }[]; +} + +export interface CrudPlan { + /** Parents before children. */ + order: PlannedEntity[]; + skipped: { name: string; reason: string }[]; +} + +export function planCrud( + entities: Entity[], + relationships: RelationshipMap +): CrudPlan { + const pending: PlannedEntity[] = []; + const skipped: CrudPlan["skipped"] = []; + for (const entity of entities) { + const payload: Record = {}; + const bad = (entity.fields || []) + .filter((field) => required(field)) + .find((field) => { + payload[field.name] = sampleValue(field); + return payload[field.name] === undefined; + }); + if (bad) { + skipped.push({ + name: entity.name, + reason: `required field "${bad.name}" has type "${bad.type}", which the smoke test can't fill`, + }); + continue; + } + const parents = (relationships[entity.name] || []) + .filter((r) => r.type === "ManyToOne" && !r.nullable) + .map((r) => ({ property: getRelationshipIdField(r), entity: r.name })); + pending.push({ name: entity.name, payload, parents }); + } + + // Topological order: take entities whose required parents are all planned. + const order: PlannedEntity[] = []; + const planned = new Set(); + let progress = true; + while (progress) { + progress = false; + for (const p of pending) { + if (planned.has(p.name)) continue; + if (p.parents.every((parent) => planned.has(parent.entity))) { + order.push(p); + planned.add(p.name); + progress = true; + } + } + } + for (const p of pending) { + if (planned.has(p.name)) continue; + const missing = p.parents + .map((parent) => parent.entity) + .filter((name) => !planned.has(name)); + skipped.push({ + name: p.name, + reason: `needs a ${missing.join( + ", " + )} row first, which can't be created (a cycle of required relationships or a skipped entity)`, + }); + } + return { order, skipped }; +} diff --git a/src/lib/verify/index.ts b/src/lib/verify/index.ts index 3b6fcd2..7da48cd 100644 --- a/src/lib/verify/index.ts +++ b/src/lib/verify/index.ts @@ -1,17 +1,22 @@ /** - * Pre-deploy checks for a generated TypeScript service, run against PGlite: - * generate the migration from the deployed schema, run it and check for drift, + * Pre-deploy checks for a generated service: generate the migration from the + * deployed schema, run it and check for drift (TypeScript only, on PGlite), * and smoke test the endpoints. The CLI and the in-browser editor (which runs * the CLI in a WebContainer) share these steps. */ -import { spawn } from "child_process"; +import { ChildProcess, spawn } from "child_process"; import * as fs from "fs"; import * as http from "http"; import * as net from "net"; import * as os from "os"; import * as path from "path"; import pluralize from "pluralize"; +import { Entity } from "../types/entity"; +import { TargetLanguage } from "../types/generator"; +import { RelationshipMap } from "../types/relationship"; +import { kebabCase } from "../utils/casing"; +import { planCrud } from "./crud-plan"; export interface Step { name: string; @@ -119,6 +124,21 @@ export async function buildBaseline( } } +/** + * Why `apso migrate` can't run for this project's language, or undefined. + * ponytail: Python (Alembic) and Go (goose) have no local scratch database to + * build the baseline on; add when they get one like PGlite for TypeScript. + */ +export const migrateUnsupported = ( + language: TargetLanguage = "typescript" +): string | undefined => { + if (language === "python") + return "apso migrate supports TypeScript services only for now. This is a Python service: use Alembic directly (alembic revision --autogenerate, alembic upgrade head, alembic check)."; + if (language === "go") + return "apso migrate supports TypeScript services only for now. This is a Go service: write goose migrations in migrations/ and run them with `go run cmd/migrate/main.go up`."; + return undefined; +}; + export interface GenerateResult { ok: boolean; needed: boolean; @@ -280,33 +300,206 @@ const freePort = () => s.on("error", reject); }); -const get = (url: string) => - new Promise<{ status: number; body: string }>((resolve) => { +type Res = { status: number; body: string }; + +const request = (method: string, url: string, body?: unknown) => + new Promise((resolve) => { + const data = body === undefined ? undefined : JSON.stringify(body); + const headers: http.OutgoingHttpHeaders = data + ? { + "content-type": "application/json", + "content-length": Buffer.byteLength(data), + } + : {}; http - .get(url, { timeout: 15_000 }, (res) => { - let body = ""; + .request(url, { method, headers, timeout: 15_000 }, (res) => { + let text = ""; res.on("data", (d) => { - body += d; + text += d; }); - res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + res.on("end", () => + resolve({ status: res.statusCode ?? 0, body: text }) + ); }) .on("timeout", function (this: http.ClientRequest) { this.destroy(new Error("No response within 15s")); }) - .on("error", (e) => resolve({ status: 0, body: e.message })); + .on("error", (e) => resolve({ status: 0, body: e.message })) + .end(data); }); +const get = (url: string) => request("GET", url); + +const expectStatus = (res: Res, ...ok: number[]) => ({ + ok: ok.includes(res.status), + output: ok.includes(res.status) + ? undefined + : `${res.status} ${tail(res.body, 500)}`, +}); + +const idOf = (body: string): unknown => { + try { + return JSON.parse(body).id ?? undefined; + } catch { + return undefined; + } +}; + +/** The route each language's generated controller serves an entity on. */ +export const entityRoute = (language: TargetLanguage, name: string): string => { + const plural = pluralize(name); + if (language === "python") return `/api/${plural.toLowerCase()}`; + if (language === "go") return `/api/${kebabCase(plural)}`; + return `/${plural}`; +}; + +/* eslint-disable no-await-in-loop */ /** - * Start the built service on a PGlite database synced to its entities and call - * GET /health and the list endpoint of every entity. - * ponytail: list only, no create/read/delete; add when payloads can be built - * from required fields and relationships. + * List every entity, then create, read and delete one row of each: parents + * before children on the way in, children before parents on the way out. + */ +async function checkEndpoints( + base: string, + language: TargetLanguage, + entities: Entity[], + relationships: RelationshipMap, + done: (s: Step) => boolean +): Promise { + const route = (name: string) => entityRoute(language, name); + for (const { name } of entities) { + done({ + name: `GET ${route(name)}`, + ...expectStatus(await get(base + route(name)), 200), + }); + } + + const plan = planCrud(entities, relationships); + for (const s of plan.skipped) + done({ name: `skip ${s.name}`, ok: true, output: s.reason }); + const ids = new Map(); + for (const p of plan.order) { + const r = route(p.name); + const failed = p.parents.find((parent) => !ids.has(parent.entity)); + if (failed) { + done({ + name: `skip ${p.name}`, + ok: true, + output: `needs a ${failed.entity} row, which failed to create`, + }); + continue; + } + const body = { ...p.payload }; + for (const parent of p.parents) + body[parent.property] = ids.get(parent.entity); + const created = await request("POST", base + r, body); + const id = [200, 201].includes(created.status) + ? idOf(created.body) + : undefined; + if ( + !done({ + name: `POST ${r}`, + ok: id !== undefined, + output: + id === undefined + ? `${created.status} ${tail(created.body, 500)}` + : undefined, + }) + ) + continue; + ids.set(p.name, id); + done({ + name: `GET ${r}/${id}`, + ...expectStatus(await get(`${base}${r}/${id}`), 200), + }); + } + for (const p of [...plan.order].reverse()) { + if (!ids.has(p.name)) continue; + const url = `${route(p.name)}/${ids.get(p.name)}`; + done({ + name: `DELETE ${url}`, + ...expectStatus(await request("DELETE", base + url), 200, 204), + }); + } +} + +/** Wait for the server's /health, run check against it, and stop the server. */ +async function serve( + server: ChildProcess, + port: number, + timeoutMs: number, + done: (s: Step) => boolean, + check: (base: string) => Promise +): Promise { + let log = ""; + server.stdout?.on("data", (d) => { + log += d; + }); + server.stderr?.on("data", (d) => { + log += d; + }); + // A command that can't start (e.g. no python3) has no pid; keep its error. + server.on("error", (e) => { + log += e.message; + }); + try { + const base = `http://127.0.0.1:${port}`; + const deadline = Date.now() + timeoutMs; + let health = await get(`${base}/health`); + while ( + health.status !== 200 && + Date.now() < deadline && + server.exitCode === null && + server.pid !== undefined + ) { + await new Promise((r) => { + setTimeout(r, 500); + }); + health = await get(`${base}/health`); + } + if ( + done({ + name: "start", + ok: health.status === 200, + output: health.status === 200 ? undefined : tail(log), + }) + ) + await check(base); + } finally { + server.kill(); + } +} +/* eslint-enable no-await-in-loop */ + +export interface SmokeOptions { + skipBuild?: boolean; + timeoutMs?: number; + language?: TargetLanguage; +} + +/** + * Start the service and call GET /health, every entity's list endpoint, and a + * create/read/delete of one row per entity. TypeScript services run on a + * PGlite database synced to their entities; Python and Go services run on the + * database in DATABASE_URL. */ export async function smokeTest( projectDir: string, - entityNames: string[], - { skipBuild = false, timeoutMs = 60_000 } = {} + entities: Entity[], + relationships: RelationshipMap = {}, + { + skipBuild = false, + timeoutMs = 60_000, + language = "typescript", + }: SmokeOptions = {} ): Promise { + if (language !== "typescript") + return smokeTestOnDatabaseUrl( + projectDir, + entities, + relationships, + language, + timeoutMs + ); if (!supportsPglite(projectDir)) return NO_PGLITE; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-")); const steps: Step[] = []; @@ -314,7 +507,6 @@ export async function smokeTest( steps.push(s); return s.ok; }; - let server: ReturnType | undefined; try { if (!skipBuild && !done(step("build", await build(projectDir)))) return { ok: false, steps }; @@ -329,59 +521,132 @@ export async function smokeTest( return { ok: false, steps }; const port = await freePort(); - let log = ""; - server = spawn(process.execPath, ["dist/main"], { + const server = spawn(process.execPath, ["dist/main"], { cwd: projectDir, env: { ...process.env, ...pgliteEnv(dataDir), APP_PORT: String(port) }, }); - server.stdout?.on("data", (d) => { - log += d; - }); - server.stderr?.on("data", (d) => { - log += d; - }); - const base = `http://127.0.0.1:${port}`; + await serve(server, port, timeoutMs, done, (base) => + checkEndpoints(base, language, entities, relationships, done) + ); + return { ok: steps.every((s) => s.ok), steps }; + } finally { + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} - const deadline = Date.now() + timeoutMs; - let health = await get(`${base}/health`); - while ( - health.status !== 200 && - Date.now() < deadline && - server.exitCode === null - ) { - // eslint-disable-next-line no-await-in-loop - await new Promise((r) => { - setTimeout(r, 500); - }); - // eslint-disable-next-line no-await-in-loop - health = await get(`${base}/health`); - } - if ( - !done({ - name: "start", - ok: health.status === 200, - output: health.status === 200 ? undefined : tail(log), - }) - ) - return { ok: false, steps }; +/** KEY=value lines of the project's .env; no interpolation. */ +export function readDotEnv(projectDir: string): Record { + let text = ""; + try { + text = fs.readFileSync(path.join(projectDir, ".env"), "utf8"); + } catch { + return {}; + } + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^\s*(?:export\s+)?([\w.]+)\s*=\s*(.*?)\s*$/); + if (m) env[m[1]] = m[2].replace(/^(["'])(.*)\1$/, "$2"); + } + return env; +} - for (const name of entityNames) { - const route = `/${pluralize(name)}`; - // eslint-disable-next-line no-await-in-loop - const res = await get(base + route); - done({ - name: `GET ${route}`, - ok: res.status === 200, - output: - res.status === 200 - ? undefined - : `${res.status} ${tail(res.body, 500)}`, - }); +const canConnect = (host: string, port: number) => + new Promise((resolve) => { + const s = net.connect({ host, port, timeout: 3000 }); + const end = (ok: boolean) => { + s.destroy(); + resolve(ok); + }; + s.on("connect", () => end(true)); + s.on("timeout", () => end(false)); + s.on("error", () => end(false)); + }); + +const skipped = (output: string): CheckResult => ({ + ok: true, + steps: [{ name: "skipped", ok: true, output }], +}); + +/** + * Python and Go services have no embedded database, so they run against the + * one in DATABASE_URL (environment first, then .env), as configured: the + * smoke test never creates tables, and it skips when no database answers. + */ +async function smokeTestOnDatabaseUrl( + projectDir: string, + entities: Entity[], + relationships: RelationshipMap, + language: TargetLanguage, + timeoutMs: number +): Promise { + const env = { ...readDotEnv(projectDir), ...process.env }; + if (!env.DATABASE_URL) + return skipped( + "No DATABASE_URL in the environment or .env, so there is no database to run the service against. Set it and rerun." + ); + let db: URL; + try { + db = new URL(env.DATABASE_URL); + } catch { + return { + ok: false, + steps: [ + { name: "database", ok: false, output: "DATABASE_URL is not a URL." }, + ], + }; + } + const dbPort = Number(db.port) || 5432; + if (!(await canConnect(db.hostname, dbPort))) + return skipped( + `No database is listening at ${db.hostname}:${dbPort} (DATABASE_URL), so the service can't start. Start the database and rerun.` + ); + + const steps: Step[] = []; + const done = (s: Step) => { + steps.push(s); + return s.ok; + }; + const port = await freePort(); + const serverEnv = { ...env, PORT: String(port) }; + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-smoke-")); + try { + let server: ChildProcess; + if (language === "go") { + // A built binary, not `go run`: killing `go run` can leave the server running. + const bin = path.join(binDir, "server"); + if ( + !done( + step( + "build", + await run("go", ["build", "-o", bin, "./cmd"], projectDir) + ) + ) + ) + return { ok: false, steps }; + server = spawn(bin, [], { cwd: projectDir, env: serverEnv }); + } else { + const venv = path.join(projectDir, ".venv", "bin", "python"); + const python = fs.existsSync(venv) ? venv : "python3"; + server = spawn( + python, + [ + "-m", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + String(port), + ], + { cwd: projectDir, env: serverEnv } + ); } + await serve(server, port, timeoutMs, done, (base) => + checkEndpoints(base, language, entities, relationships, done) + ); return { ok: steps.every((s) => s.ok), steps }; } finally { - server?.kill(); - fs.rmSync(dataDir, { recursive: true, force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); } } diff --git a/test/lib/generators/go.test.ts b/test/lib/generators/go.test.ts index 5d97d99..52a22e7 100644 --- a/test/lib/generators/go.test.ts +++ b/test/lib/generators/go.test.ts @@ -76,6 +76,44 @@ describe("GoGenerator", () => { expect(content).toContain('return "blog_post"'); }); + test("honors an explicit per-entity table override (cli#107)", async () => { + const entity: Entity = { + name: "Order", + table: "order_record", + fields: [{ name: "total", type: "integer" }], + }; + + const files = await generator.generateEntity({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + + const content = findFileContent(files, "order.go"); + expect(content).toBeDefined(); + expect(content).toContain('return "order_record"'); + // must NOT fall back to the reserved-word snake_case name + expect(content).not.toContain('return "order"'); + }); + + test("falls back to snake_case of the name when no override is set (cli#107)", async () => { + const entity: Entity = { + name: "Order", + fields: [{ name: "total", type: "integer" }], + }; + + const files = await generator.generateEntity({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + + const content = findFileContent(files, "order.go"); + expect(content).toContain('return "order"'); + }); + test("output path uses snake_case filename", async () => { const entity: Entity = { name: "UserProfile", diff --git a/test/lib/generators/python.test.ts b/test/lib/generators/python.test.ts index 2bf4809..4f37da3 100644 --- a/test/lib/generators/python.test.ts +++ b/test/lib/generators/python.test.ts @@ -73,6 +73,46 @@ describe("PythonGenerator", () => { }); }); + describe("table name override (cli#107)", () => { + test("honors an explicit per-entity table override", async () => { + const entity: Entity = { + name: "Order", + table: "order_record", + fields: [{ name: "total", type: "integer", nullable: false }], + }; + + const files = await generator.generateEntity({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + + const modelContent = findFileContent(files, "order.py"); + expect(modelContent).toBeDefined(); + expect(modelContent).toContain('__tablename__ = "order_record"'); + // must NOT fall back to the reserved-word snake_case name + expect(modelContent).not.toContain('__tablename__ = "order"'); + }); + + test("falls back to snake_case of the name when no override is set", async () => { + const entity: Entity = { + name: "Order", + fields: [{ name: "total", type: "integer", nullable: false }], + }; + + const files = await generator.generateEntity({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + + const modelContent = findFileContent(files, "order.py"); + expect(modelContent).toContain('__tablename__ = "order"'); + }); + }); + describe("default value handling", () => { test("string defaults render without HTML encoding", async () => { const entity: Entity = { @@ -277,6 +317,27 @@ describe("PythonGenerator", () => { expect(modelContent).toContain("ForeignKey("); expect(modelContent).toContain("relationship("); }); + + test("ManyToOne FK names the parent's real table (override, multi-word)", async () => { + const order: Entity = { name: "Order", table: "order_record", fields: [] }; + const profile: Entity = { name: "UserProfile", fields: [] }; + const item: Entity = { name: "LineItem", fields: [] }; + const relationships: Relationship[] = [ + { type: "ManyToOne", name: "Order", referenceName: "Order" }, + { type: "ManyToOne", name: "UserProfile", referenceName: "UserProfile" }, + ]; + + const files = await generator.generateEntity({ + entity: item, + relationships, + allEntities: [order, profile, item], + apiType: "rest", + }); + + const modelContent = findFileContent(files, "lineitem.py"); + expect(modelContent).toContain('ForeignKey("order_record.id")'); + expect(modelContent).toContain('ForeignKey("user_profile.id")'); + }); }); describe("validation", () => { diff --git a/test/lib/verify/crud-plan.test.ts b/test/lib/verify/crud-plan.test.ts new file mode 100644 index 0000000..b0931b6 --- /dev/null +++ b/test/lib/verify/crud-plan.test.ts @@ -0,0 +1,103 @@ +import { expect, describe, test } from "@jest/globals"; +import { planCrud, sampleValue } from "../../../src/lib/verify/crud-plan"; +import { entityRoute, readDotEnv } from "../../../src/lib/verify"; +import { parseRelationships } from "../../../src/lib/utils/relationships"; +import { Entity } from "../../../src/lib/types"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +describe("sampleValue", () => { + test("fills each supported type", () => { + expect(sampleValue({ name: "a", type: "text" })).toBe("smoke"); + expect(sampleValue({ name: "a", type: "text", length: 3 })).toBe("smo"); + expect(sampleValue({ name: "a", type: "text", is_email: true })).toBe("smoke@example.com"); + expect(sampleValue({ name: "a", type: "integer" })).toBe(1); + expect(sampleValue({ name: "a", type: "decimal" })).toBe(1.5); + expect(sampleValue({ name: "a", type: "boolean" })).toBe(true); + expect(sampleValue({ name: "a", type: "timestamptz" })).toBe("2024-01-01T00:00:00.000Z"); + expect(sampleValue({ name: "a", type: "enum", values: ["gold", "silver"] })).toBe("gold"); + expect(sampleValue({ name: "a", type: "json" })).toEqual({}); + expect(sampleValue({ name: "a", type: "uuid" as never })).toMatch(/^[\da-f-]{36}$/); + }); + + test("returns undefined for a type it can't fill", () => { + expect(sampleValue({ name: "a", type: "array" })).toBeUndefined(); + }); +}); + +describe("planCrud", () => { + const customer: Entity = { + name: "Customer", + fields: [ + { name: "name", type: "text" }, + { name: "nickname", type: "text", nullable: true }, + { name: "score", type: "integer", default: "0" }, + ], + }; + const order: Entity = { name: "Order", fields: [{ name: "total", type: "decimal" }] }; + const lineItem: Entity = { name: "LineItem", fields: [{ name: "qty", type: "integer" }] }; + + test("sends only required fields", () => { + const { order: planned } = planCrud([customer], {}); + expect(planned[0].payload).toEqual({ name: "smoke" }); + }); + + test("orders parents before children and names the foreign keys", () => { + const relationships = parseRelationships([ + { from: "LineItem", to: "Order", type: "ManyToOne" }, + { from: "Customer", to: "Order", type: "OneToMany", to_name: "purchases" }, + ]); + const plan = planCrud([lineItem, order, customer], relationships); + expect(plan.order.map((p) => p.name)).toEqual(["Customer", "Order", "LineItem"]); + expect(plan.order[1].parents).toEqual([{ property: "customerId", entity: "Customer" }]); + expect(plan.order[2].parents).toEqual([{ property: "orderId", entity: "Order" }]); + expect(plan.skipped).toEqual([]); + }); + + test("uses to_name for the foreign key and ignores nullable parents", () => { + const relationships = parseRelationships([ + { from: "Order", to: "Customer", type: "ManyToOne", to_name: "buyer" }, + { from: "LineItem", to: "Order", type: "ManyToOne", nullable: true }, + ]); + const plan = planCrud([customer, order, lineItem], relationships); + expect(plan.order.find((p) => p.name === "Order")?.parents).toEqual([ + { property: "buyerId", entity: "Customer" }, + ]); + expect(plan.order.find((p) => p.name === "LineItem")?.parents).toEqual([]); + }); + + test("skips cycles, unsupported types, and their children", () => { + const tags: Entity = { name: "Tagged", fields: [{ name: "tags", type: "array" }] }; + const note: Entity = { name: "Note", fields: [] }; + const a: Entity = { name: "A", fields: [] }; + const b: Entity = { name: "B", fields: [] }; + const relationships = parseRelationships([ + { from: "Note", to: "Tagged", type: "ManyToOne" }, + { from: "A", to: "B", type: "ManyToOne", bi_directional: false }, + { from: "B", to: "A", type: "ManyToOne", bi_directional: false }, + ]); + const plan = planCrud([tags, note, a, b, customer], relationships); + expect(plan.order.map((p) => p.name)).toEqual(["Customer"]); + expect(plan.skipped.map((s) => s.name).sort()).toEqual(["A", "B", "Note", "Tagged"]); + expect(plan.skipped.find((s) => s.name === "Tagged")?.reason).toContain('"array"'); + expect(plan.skipped.find((s) => s.name === "Note")?.reason).toContain("Tagged"); + }); +}); + +describe("entityRoute", () => { + test("matches each language's generated controller", () => { + expect(entityRoute("typescript", "LineItem")).toBe("/LineItems"); + expect(entityRoute("python", "LineItem")).toBe("/api/lineitems"); + expect(entityRoute("go", "LineItem")).toBe("/api/line-items"); + }); +}); + +describe("readDotEnv", () => { + test("reads KEY=value lines and strips quotes", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-env-")); + fs.writeFileSync(path.join(dir, ".env"), '# db\nDATABASE_URL="postgres://u:p@h:5433/d"\nPORT=3000\n'); + expect(readDotEnv(dir)).toEqual({ DATABASE_URL: "postgres://u:p@h:5433/d", PORT: "3000" }); + expect(readDotEnv(path.join(dir, "missing"))).toEqual({}); + }); +}); diff --git a/test/lib/verify/supports-pglite.test.ts b/test/lib/verify/supports-pglite.test.ts index cd5214a..08e6368 100644 --- a/test/lib/verify/supports-pglite.test.ts +++ b/test/lib/verify/supports-pglite.test.ts @@ -24,7 +24,7 @@ describe("supportsPglite", () => { }); test("checks pass as skipped instead of failing a v1 project", async () => { - const result = await smokeTest(project("type: 'postgres'"), ["Widget"], { skipBuild: true }); + const result = await smokeTest(project("type: 'postgres'"), [{ name: "Widget" }], {}, { skipBuild: true }); expect(result.ok).toBe(true); expect(result.steps.map((s) => s.name)).toEqual(["skipped"]); });