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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions scripts/scaffold-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<file>.apsorc to generate from a fixture.
# Optional (go): SMOKE_DATABASE_URL=<empty Postgres> to also run `apso test smoke`.
set -euo pipefail

LANGUAGE="${1:-typescript}"
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/commands/migrate/generate.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -26,6 +32,8 @@ export default class MigrateGenerate extends BaseCommand {

async run(): Promise<void> {
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),
Expand Down
10 changes: 9 additions & 1 deletion src/commands/migrate/verify.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -32,6 +38,8 @@ export default class MigrateVerify extends BaseCommand {

async run(): Promise<void> {
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),
Expand Down
23 changes: 15 additions & 8 deletions src/commands/test/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -22,17 +23,23 @@ export default class TestSmoke extends BaseCommand {

async run(): Promise<void> {
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);
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/generators/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
7 changes: 7 additions & 0 deletions src/lib/generators/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
4 changes: 2 additions & 2 deletions src/lib/templates/go/models/model.eta
Original file line number Diff line number Diff line change
Expand Up @@ -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') { %>
Expand Down Expand Up @@ -48,5 +48,5 @@ type <%= it.name %> struct {

// TableName overrides the table name
func (<%= it.name %>) TableName() string {
return "<%= it.snakeCasedName %>"
return "<%= it.tableName %>"
}
16 changes: 8 additions & 8 deletions src/lib/templates/python/models/model.eta
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,25 @@ 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),
)
<% }) %>

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 %>"<% } %>),
Expand Down Expand Up @@ -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<% } %>
)
Expand All @@ -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"
)
Expand Down
124 changes: 124 additions & 0 deletions src/lib/verify/crud-plan.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
/** 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<string, unknown> = {};
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<string>();
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 };
}
Loading
Loading