Skip to content

Commit

Permalink
chore: Break up codegen/index.ts.
Browse files Browse the repository at this point in the history
  • Loading branch information
stephenh committed May 30, 2024
1 parent ffe1fd1 commit b6eb5c9
Show file tree
Hide file tree
Showing 5 changed files with 288 additions and 266 deletions.
2 changes: 1 addition & 1 deletion packages/codegen/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { promises as fs } from "fs";
import { groupBy } from "joist-utils";
import { z } from "zod";
import { getThisVersion } from "./codemods";
import { getStiEntities } from "./index";
import { getStiEntities } from "./inheritance";
import { fail, sortKeys, trueIfResolved } from "./utils";

const jsonFormatter = createFromBuffer(getBuffer());
Expand Down
66 changes: 66 additions & 0 deletions packages/codegen/src/foreignKeyOrdering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { promises as fs } from "fs";
import { ManyToOneField, PolymorphicFieldComponent } from "./EntityDbMetadata";
import { Config, EntityDbMetadata } from "./index";
import { sortByNonDeferredForeignKeys } from "./sortForeignKeys";

export async function maybeSetForeignKeyOrdering(config: Config, entities: EntityDbMetadata[]): Promise<boolean> {
let hasError = false;

// Hopefully all FKs are deferred, but if not...
const hasAnyNonDeferredFks = entities.some((e) => e.nonDeferredFks.length > 0);
if (!hasAnyNonDeferredFks) {
// Great, nothing to do
for (const entity of entities) entity.nonDeferredFkOrder = 0;
} else {
// Always sort the non-deferred FKs to establish insert/flush order
const { notNullCycles } = sortByNonDeferredForeignKeys(entities);

// Log the mere existence of the non-deferred FKs
const { nonDeferredForeignKeys: setting = "warn" } = config;
const nonDeferredFks = entities.flatMap((e) => e.nonDeferredFks.map((m2o) => ({ entity: e, m2o })));
if (setting === "error" || setting === "warn") {
const flag = setting === "error" ? "ERROR" : "WARNING";
console.log(`${flag}: Found ${nonDeferredFks.length} foreign keys that are not DEFERRABLE/INITIALLY DEFERRED`);
for (const { entity, m2o } of nonDeferredFks) console.log(`${entity.tableName}.${m2o.columnName}`);
console.log("");

console.log("Please either:");
console.log(" - Alter your migration to create the FKs as deferred (ideal)");
console.log(" - Execute the generated alter-foreign-keys.sql file (one-time fix)");
console.log(" - Set 'nonDeferredFks: ignore' in joist-config.json");
console.log("");

console.log("See https://joist-orm.io/docs/getting-started/schema-assumptions#deferred-constraints");
console.log("");

await writeAlterTables(nonDeferredFks);

if (setting === "error") hasError = true;
} else if (setting === "ignore") {
// We trust the user to know what they're doing
}

// Always treat these as errors?
if (notNullCycles.length > 0) {
console.log(`ERROR: Found a schema cycle of not-null foreign keys:`);
notNullCycles.forEach((cycle) => console.log(cycle));
console.log("");
console.log("These cycles can cause fatal em.flush & flush_test_database errors.");
console.log("");
console.log("Please make one of the FKs involved in the cycle nullable.");
console.log("");
hasError ??= true;
}
}

return hasError;
}

async function writeAlterTables(
nonDeferredFks: Array<{ entity: EntityDbMetadata; m2o: ManyToOneField | PolymorphicFieldComponent }>,
): Promise<void> {
const queries = nonDeferredFks.map(({ entity, m2o }) => {
return `ALTER TABLE ${entity.tableName} ALTER CONSTRAINT ${m2o.constraintName} DEFERRABLE INITIALLY DEFERRED;`;
});
await fs.writeFile("./alter-foreign-keys.sql", queries.join("\n"));
}
2 changes: 1 addition & 1 deletion packages/codegen/src/generateEntityCodegenFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
PrimitiveTypescriptType,
} from "./EntityDbMetadata";
import { Config, hasConfigDefault } from "./config";
import { getStiEntities } from "./index";
import { getStiEntities } from "./inheritance";
import { keywords } from "./keywords";
import {
BaseEntity,
Expand Down
274 changes: 10 additions & 264 deletions packages/codegen/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
import { promises as fs } from "fs";
import { ConnectionConfig, newPgConnectionConfig } from "joist-utils";
import { Client } from "pg";
import pgStructure from "pg-structure";
import { saveFiles } from "ts-poet";
import {
DbMetadata,
EntityDbMetadata,
ManyToOneField,
PolymorphicFieldComponent,
failIfOverlappingFieldNames,
makeEntity,
} from "./EntityDbMetadata";
import { DbMetadata, EntityDbMetadata, failIfOverlappingFieldNames } from "./EntityDbMetadata";
import { assignTags } from "./assignTags";
import { maybeRunTransforms } from "./codemods";
import { Config, loadConfig, stripStiPlaceholders, warnInvalidConfigEntries, writeConfig } from "./config";
import { maybeSetForeignKeyOrdering } from "./foreignKeyOrdering";
import { generateFiles } from "./generate";
import { createFlushFunction } from "./generateFlushFunction";
import { applyInheritanceUpdates } from "./inheritance";
import { loadEnumMetadata, loadPgEnumMetadata } from "./loadMetadata";
import { sortByNonDeferredForeignKeys } from "./sortForeignKeys";
import { fail, isEntityTable, isJoinTable, mapSimpleDbTypeToTypescriptType } from "./utils";
import { isEntityTable, isJoinTable, mapSimpleDbTypeToTypescriptType } from "./utils";

export {
DbMetadata,
Expand Down Expand Up @@ -50,56 +43,15 @@ async function main() {
);
console.log("");

let hasError = false;

// Hopefully all FKs are deferred, but if not...
const hasAnyNonDeferredFks = entities.some((e) => e.nonDeferredFks.length > 0);
if (!hasAnyNonDeferredFks) {
// Great, nothing to do
for (const entity of entities) entity.nonDeferredFkOrder = 0;
} else {
// Always sort the non-deferred FKs to establish insert/flush order
const { notNullCycles } = sortByNonDeferredForeignKeys(entities);

// Log the mere existence of the non-deferred FKs
const { nonDeferredForeignKeys: setting = "warn" } = config;
const nonDeferredFks = entities.flatMap((e) => e.nonDeferredFks.map((m2o) => ({ entity: e, m2o })));
if (setting === "error" || setting === "warn") {
const flag = setting === "error" ? "ERROR" : "WARNING";
console.log(`${flag}: Found ${nonDeferredFks.length} foreign keys that are not DEFERRABLE/INITIALLY DEFERRED`);
for (const { entity, m2o } of nonDeferredFks) console.log(`${entity.tableName}.${m2o.columnName}`);
console.log("");
// Look for STI tables to synthesize separate metas
applyInheritanceUpdates(config, dbMetadata);

console.log("Please either:");
console.log(" - Alter your migration to create the FKs as deferred (ideal)");
console.log(" - Execute the generated alter-foreign-keys.sql file (one-time fix)");
console.log(" - Set 'nonDeferredFks: ignore' in joist-config.json");
console.log("");

console.log("See https://joist-orm.io/docs/getting-started/schema-assumptions#deferred-constraints");
console.log("");

await writeAlterTables(nonDeferredFks);

if (setting === "error") hasError = true;
} else if (setting === "ignore") {
// We trust the user to know what they're doing
}

// Always treat these as errors?
if (notNullCycles.length > 0) {
console.log(`ERROR: Found a schema cycle of not-null foreign keys:`);
notNullCycles.forEach((cycle) => console.log(cycle));
console.log("");
console.log("These cycles can cause fatal em.flush & flush_test_database errors.");
console.log("");
console.log("Please make one of the FKs involved in the cycle nullable.");
console.log("");
hasError ??= true;
}
}
// If we're not using deferred FKs, determine our DAG insert order
const hasError = await maybeSetForeignKeyOrdering(config, dbMetadata.entities);

// Generate the flush function for tests
await maybeGenerateFlushFunctions(config, client, pgConfig, dbMetadata);

await client.end();

// Apply any codemods to the user's codebase, if we have them
Expand Down Expand Up @@ -169,206 +121,9 @@ async function loadSchemaMetadata(config: Config, client: Client): Promise<DbMet
const totalTables = db.tables.length;
const joinTables = db.tables.filter((t) => isJoinTable(config, t)).map((t) => t.name);
const entitiesByName = Object.fromEntries(entities.map((e) => [e.name, e]));
setClassTableInheritance(entities, entitiesByName);
expandSingleTableInheritance(config, entities);
rewriteSingleTableForeignKeys(config, entities);
return { entities, entitiesByName, enums, pgEnums, totalTables, joinTables };
}

/**
* Ensure CTI base types have their inheritanceType set.
*
* (We automatically set `inheritanceType` for STI tables when we see
* their config setup, see `expandSingleTableInheritance`.)
*/
function setClassTableInheritance(
entities: EntityDbMetadata[],
entitiesByName: Record<string, EntityDbMetadata>,
): void {
const ctiBaseNames: string[] = [];
for (const entity of entities) {
if (entity.baseClassName) {
ctiBaseNames.push(entity.baseClassName);
entitiesByName[entity.baseClassName].subTypes.push(entity);
}
}
for (const entity of entities) {
if (ctiBaseNames.includes(entity.name)) entity.inheritanceType = "cti";
}
}

/** Expands STI tables into multiple entities, so they get separate `SubTypeCodegen.ts` & `SubType.ts` files. */
function expandSingleTableInheritance(config: Config, entities: EntityDbMetadata[]): void {
for (const entity of entities) {
const [fieldName, stiField] =
Object.entries(config.entities[entity.name]?.fields || {}).find(([, f]) => !!f.stiDiscriminator) ?? [];
if (fieldName && stiField && stiField.stiDiscriminator) {
entity.inheritanceType = "sti";
// Ensure we have an enum field so that we can bake the STI discriminators into the metadata.ts file
const enumField =
entity.enums.find((e) => e.fieldName === fieldName) ??
fail(`No enum column found for ${entity.name}.${fieldName}, which is required to use singleTableInheritance`);
entity.stiDiscriminatorField = enumField.fieldName;
for (const [enumCode, subTypeName] of Object.entries(stiField.stiDiscriminator)) {
// Find all the base entity's fields that belong to us
const subTypeFields = [
...Object.entries(config.entities[entity.name]?.fields ?? {}),
...Object.entries(config.entities[entity.name]?.relations ?? {}),
].filter(([, f]) => f.stiType === subTypeName);
const subTypeFieldNames = subTypeFields.map(([name]) => name);

// Make fields as required
function maybeRequired<T extends { notNull: boolean; fieldName: string }>(field: T): T {
const config = subTypeFields.find(([name]) => name === field.fieldName)?.[1]!;
if (config.stiNotNull) field.notNull = true;
return field;
}

// Synthesize an entity for this STI sub-entity
const subEntity: EntityDbMetadata = {
name: subTypeName,
entity: makeEntity(subTypeName),
tableName: entity.tableName,
primaryKey: entity.primaryKey,
primitives: entity.primitives.filter((f) => subTypeFieldNames.includes(f.fieldName)).map(maybeRequired),
enums: entity.enums.filter((f) => subTypeFieldNames.includes(f.fieldName)).map(maybeRequired),
pgEnums: entity.pgEnums.filter((f) => subTypeFieldNames.includes(f.fieldName)).map(maybeRequired),
manyToOnes: entity.manyToOnes.filter((f) => subTypeFieldNames.includes(f.fieldName)).map(maybeRequired),
oneToManys: entity.oneToManys.filter((f) => subTypeFieldNames.includes(f.fieldName)),
largeOneToManys: entity.largeOneToManys.filter((f) => subTypeFieldNames.includes(f.fieldName)),
oneToOnes: entity.oneToOnes.filter((f) => subTypeFieldNames.includes(f.fieldName)),
manyToManys: entity.manyToManys.filter((f) => subTypeFieldNames.includes(f.fieldName)),
largeManyToManys: entity.largeManyToManys.filter((f) => subTypeFieldNames.includes(f.fieldName)),
polymorphics: entity.polymorphics.filter((f) => subTypeFieldNames.includes(f.fieldName)),
tagName: entity.tagName,
createdAt: undefined,
updatedAt: undefined,
deletedAt: undefined,
baseClassName: entity.name,
subTypes: [],
inheritanceType: "sti",
stiDiscriminatorValue: (
enumField.enumRows.find((r) => r.code === enumCode) ??
fail(`No enum row found for ${entity.name}.${fieldName}.${enumCode}`)
).id,
abstract: false,
nonDeferredFkOrder: entity.nonDeferredFkOrder,
get nonDeferredFks(): Array<ManyToOneField | PolymorphicFieldComponent> {
return [
...this.manyToOnes.filter((r) => !r.isDeferredAndDeferrable),
...this.polymorphics.flatMap((p) => p.components).filter((c) => !c.isDeferredAndDeferrable),
];
},
};

// Now strip all the subclass fields from the base class
entity.primitives = entity.primitives.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.enums = entity.enums.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.pgEnums = entity.pgEnums.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.manyToOnes = entity.manyToOnes.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.oneToManys = entity.oneToManys.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.largeOneToManys = entity.largeOneToManys.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.oneToOnes = entity.oneToOnes.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.manyToManys = entity.manyToManys.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.largeManyToManys = entity.largeManyToManys.filter((f) => !subTypeFieldNames.includes(f.fieldName));
entity.polymorphics = entity.polymorphics.filter((f) => !subTypeFieldNames.includes(f.fieldName));

entities.push(subEntity);
}
}
}
}

type StiEntityMap = Map<string, { base: EntityDbMetadata; subTypes: EntityDbMetadata[] }>;
let stiEntities: StiEntityMap;

export function getStiEntities(entities: EntityDbMetadata[]): StiEntityMap {
if (stiEntities) return stiEntities;
stiEntities = new Map();
for (const entity of entities) {
if (entity.inheritanceType === "sti" && entity.stiDiscriminatorField) {
const base = entity;
const subTypes = entities.filter((s) => s.baseClassName === entity.name && s !== entity);
stiEntities.set(entity.name, { base, subTypes });
// Allow looking up by subType name
for (const subType of subTypes) {
stiEntities.set(subType.name, { base, subTypes: [] });
}
}
}
return stiEntities;
}

/** Finds FKs pointing to the base table and, if configured, rewrites them to point to the sub-tables. */
function rewriteSingleTableForeignKeys(config: Config, entities: EntityDbMetadata[]): void {
// See if we even have any STI tables
const stiEntities = getStiEntities(entities);
if (stiEntities.size === 0) return;
// Scan for other entities/relations that point to the STI table
for (const entity of entities) {
// m2os -- Look for `entity.task_id` FKs pointing at `Task` and, if configured, rewrite them to point at `TaskOld`
for (const m2o of entity.manyToOnes) {
const target = stiEntities.get(m2o.otherEntity.name);
const base = target?.base.entity.name;
// See if the user has pushed `Task.entities` down to a subtype
const stiType = base && config.entities[base]?.relations?.[m2o.otherFieldName]?.stiType;
if (target && stiType) {
const { subTypes } = target;
m2o.otherEntity = (
subTypes.find((s) => s.name === stiType) ??
fail(`Could not find STI type '${stiType}' in ${subTypes.map((s) => s.name)}`)
).entity;
}
}
// polys -- Look for `entity.parent_task_id` FKs pointing at `Task` and, if configured, rewrite them to point at `TaskOld`
for (const poly of entity.polymorphics) {
for (const comp of poly.components) {
const target = stiEntities.get(comp.otherEntity.name);
const base = target?.base.entity.name;
// See if the user has pushed `Task.entities` down to a subtype
const stiType = base && config.entities[base]?.relations?.[comp.otherFieldName]?.stiType;
if (target && stiType) {
const { subTypes } = target;
comp.otherEntity = (
subTypes.find((s) => s.name === stiType) ??
fail(`Could not find STI type '${stiType}' in ${subTypes.map((s) => s.name)}`)
).entity;
}
}
}
// o2ms -- Look for `entity.tasks` collections loading `task.entity_id`, but entity has been pushed down to `TaskOld`
for (const o2m of entity.oneToManys) {
const target = stiEntities.get(o2m.otherEntity.name);
if (target && target.base.inheritanceType === "sti") {
// Ensure the incoming FK is not in the base type, and find the 1st subtype (eventually N subtypes?)
const otherField = target.subTypes.find(
(st) =>
!target.base.manyToOnes.some((m) => m.fieldName === o2m.otherFieldName) &&
st.manyToOnes.some((m) => m.fieldName === o2m.otherFieldName),
);
if (otherField) {
o2m.otherEntity = otherField.entity;
}
}
}
// m2ms -- Look for `entity.tasks` collections loading m2m rows, but `entity` has been pushed down to `TaskOld`
for (const m2m of entity.manyToManys) {
const target = stiEntities.get(m2m.otherEntity.name);
if (target && target.base.inheritanceType === "sti") {
// Ensure the incoming FK is not in the base type, and find the 1st subtype (eventually N subtypes?)
const otherField = target.subTypes.find(
(st) =>
!target.base.manyToManys.some((m) => m.fieldName === m2m.otherFieldName) &&
st.manyToManys.some((m) => m.fieldName === m2m.otherFieldName),
);
if (otherField) {
m2m.otherEntity = otherField.entity;
}
}
}
}
}

function maybeSetDatabaseUrl(config: Config): void {
if (!process.env.DATABASE_URL && config.databaseUrl) {
process.env.DATABASE_URL = config.databaseUrl;
Expand All @@ -384,12 +139,3 @@ if (require.main === module) {
process.exit(1);
});
}

export async function writeAlterTables(
nonDeferredFks: Array<{ entity: EntityDbMetadata; m2o: ManyToOneField | PolymorphicFieldComponent }>,
): Promise<void> {
const queries = nonDeferredFks.map(({ entity, m2o }) => {
return `ALTER TABLE ${entity.tableName} ALTER CONSTRAINT ${m2o.constraintName} DEFERRABLE INITIALLY DEFERRED;`;
});
await fs.writeFile("./alter-foreign-keys.sql", queries.join("\n"));
}
Loading

0 comments on commit b6eb5c9

Please sign in to comment.