Skip to content

Commit

Permalink
feat: Support schemas with non-deferred FKs.
Browse files Browse the repository at this point in the history
  • Loading branch information
stephenh committed May 28, 2024
1 parent 43187dd commit 74ff45e
Show file tree
Hide file tree
Showing 77 changed files with 3,309 additions and 71 deletions.
3 changes: 2 additions & 1 deletion .idea/joist-orm.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/jsLibraryMappings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions db.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ RUN echo "#!/bin/bash" > /reset.sh && \
echo " CREATE DATABASE untagged_ids OWNER ${APP_USERNAME};" >> /reset.sh && \
echo " DROP DATABASE IF EXISTS temporal WITH (FORCE);" >> /reset.sh && \
echo " CREATE DATABASE temporal OWNER ${APP_USERNAME};" >> /reset.sh && \
echo " DROP DATABASE IF EXISTS immediate_foreign_keys WITH (FORCE);" >> /reset.sh && \
echo " CREATE DATABASE immediate_foreign_keys OWNER ${APP_USERNAME};" >> /reset.sh && \
echo "EOSQL" >> /reset.sh && \
chmod uo+x /reset.sh

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/getting-started/tour.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class AuthorCodegen {
// ...all the boilerplate fields & m2o/o2m/m2m relations generated for you...
readonly books: Collection<Author, Book> = hasOne(...);
get firstName(): string { ... }
set firstName(): string { ... }
set firstName(value: string): string { ... }
}
```

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"packages/tests/integration",
"packages/tests/schema-misc",
"packages/tests/esm-misc",
"packages/tests/immediate-foreign-keys",
"packages/tests/number-ids",
"packages/tests/untagged-ids",
"packages/tests/uuid-ids",
Expand Down
22 changes: 17 additions & 5 deletions packages/codegen/src/EntityDbMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { camelCase, pascalCase, snakeCase } from "change-case";
import { groupBy } from "joist-utils";
import { Column, EnumType, Index, JSONData, M2MRelation, M2ORelation, O2MRelation, Table } from "pg-structure";
import { Action, Column, EnumType, Index, JSONData, M2MRelation, M2ORelation, O2MRelation, Table } from "pg-structure";
import { plural, singular } from "pluralize";
import { Code, Import, code, imp } from "ts-poet";
import {
Expand Down Expand Up @@ -34,6 +34,8 @@ import {
/** All the entities + enums in our database. */
export interface DbMetadata {
entities: EntityDbMetadata[];
/** Type name i.e. `Author` to metadata. */
entitiesByName: Record<string, EntityDbMetadata>;
enums: EnumMetadata;
pgEnums: PgEnumMetadata;
joinTables: string[];
Expand Down Expand Up @@ -156,6 +158,7 @@ export type ManyToOneField = Field & {
isDeferredAndDeferrable: boolean;
derived: "async" | false;
hasConfigDefault: boolean;
onDelete: Action;
};

/** I.e. a `Author.books` collection. */
Expand Down Expand Up @@ -202,6 +205,8 @@ export type PolymorphicFieldComponent = {
columnName: string; // eg `parent_book_id` or `parent_book_review_id`
otherFieldName: string; // eg `comment` or `comments`
otherEntity: Entity;
isDeferredAndDeferrable: boolean;
notNull: false; // Added to structurally match ManyToOneField
};

export type FieldNameOverrides = {
Expand Down Expand Up @@ -236,7 +241,7 @@ export class EntityDbMetadata {
/** This will only be set on the sub metas. */
stiDiscriminatorValue?: number;
abstract: boolean;
invalidDeferredFK: boolean;
nonDeferredFkOrder: number = -1;

constructor(config: Config, table: Table, enums: EnumMetadata = {}) {
this.entity = makeEntity(tableToEntityName(config, table));
Expand Down Expand Up @@ -283,8 +288,6 @@ export class EntityDbMetadata {
.map((r) => newManyToOneField(config, this.entity, r))
.filter((f) => !f.ignore);

this.invalidDeferredFK = this.manyToOnes.some((r) => !r.isDeferredAndDeferrable);

// We split these into regular/large...
const allOneToManys = table.o2mRelations
// ManyToMany join tables also show up as OneToMany tables in pg-structure
Expand Down Expand Up @@ -337,6 +340,13 @@ export class EntityDbMetadata {
get name(): string {
return this.entity.name;
}

get nonDeferredFks(): Array<ManyToOneField | PolymorphicFieldComponent> {
return [
...this.manyToOnes.filter((r) => !r.isDeferredAndDeferrable),
...this.polymorphics.flatMap((p) => p.components).filter((c) => !c.isDeferredAndDeferrable),
];
}
}

function isMultiColumnForeignKey(r: M2ORelation | O2MRelation | M2MRelation) {
Expand Down Expand Up @@ -559,6 +569,7 @@ function newManyToOneField(config: Config, entity: Entity, r: M2ORelation): Many
dbType,
isDeferredAndDeferrable,
hasConfigDefault: hasConfigDefault(config, entity, fieldName),
onDelete: r.foreignKey.onDelete,
};
}

Expand Down Expand Up @@ -637,7 +648,8 @@ function newPolymorphicFieldComponent(config: Config, entity: Entity, r: M2ORela
const otherFieldName = isOneToOne
? oneToOneName(config, otherEntity, entity, r)
: collectionName(config, otherEntity, entity, r).fieldName;
return { columnName, otherEntity, otherFieldName };
const isDeferredAndDeferrable = r.foreignKey.isDeferred && r.foreignKey.isDeferrable;
return { columnName, otherEntity, otherFieldName, isDeferredAndDeferrable, notNull: false };
}

function isFieldNameOverrides(maybeOverride: JSONData | undefined): maybeOverride is FieldNameOverrides {
Expand Down
12 changes: 1 addition & 11 deletions packages/codegen/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,9 @@ export async function generateFiles(config: Config, dbMeta: DbMetadata): Promise

const contextType = config.contextType ? imp(`t:${config.contextType}`) : "{}";

// We want to hard-stop the app from booting if there are any invalid deferred FKs
const invalidEntities = entities.filter((e) => e.invalidDeferredFK);

const metadataFile: CodegenFile = {
name: "./codegen/metadata.ts",
contents: code`
${
invalidEntities.length > 0
? `throw new Error('Misconfigured Foreign Keys found in the following entities: ${invalidEntities
.map((e) => e.name)
.join(", ")}');`
: ``
}
export class ${def("EntityManager")} extends ${JoistEntityManager}<${contextType}, Entity> {}
export interface ${def("Entity")} extends ${Entity} {
Expand Down Expand Up @@ -113,7 +103,7 @@ export async function generateFiles(config: Config, dbMeta: DbMetadata): Promise

const indexFile: CodegenFile = {
name: "./index.ts",
contents: code`export * from "./entities${config.esm ? '.js' : ''}"`,
contents: code`export * from "./entities${config.esm ? ".js" : ""}"`,
overwrite: false,
};

Expand Down
31 changes: 27 additions & 4 deletions packages/codegen/src/generateFlushFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ export function generateFlushFunction(db: DbMetadata): string {
// Leave code/enum tables alone
const tables = [
...[...db.entities]
// Flush base tables before sub-tables.
.sort((a, b) => {
// Flush books before authors if it has non-deferred FKs
const x = a.nonDeferredFkOrder;
const y = b.nonDeferredFkOrder;
if (x !== y) {
return y - x;
}
// Flush base tables before sub-tables.
const i = a.baseClassName ? 1 : -1;
const j = b.baseClassName ? 1 : -1;
return j - i;
Expand All @@ -27,9 +33,26 @@ export function generateFlushFunction(db: DbMetadata): string {
// One cute idea would be to use a single sequence for all tables when running locally. That would
// mean our flush_database function could reset a single sequence. Plus it would reduce bugs where
// something "works" but only b/c in the test database, all entities have id = 1.
const statements = tables
.map((t) => `DELETE FROM "${t}"; ALTER SEQUENCE IF EXISTS "${t}_id_seq" RESTART WITH 1 INCREMENT BY 1;`)
.join("\n");
const deletes = tables.map(
(t) => `DELETE FROM "${t}"; ALTER SEQUENCE IF EXISTS "${t}_id_seq" RESTART WITH 1 INCREMENT BY 1;`,
);

// Create `SET NULLs` in schemas that don't have deferred FKs
const setFksNulls = db.entities
.filter((t) => t.nonDeferredFkOrder)
.flatMap((t) =>
t.manyToOnes
// These FKs will auto-unset/delete the later row, so don't need explicit unsetting
.filter((m2o) => m2o.onDelete !== "SET NULL" && m2o.onDelete !== "CASCADE")
// Look for FKs to tables whose DELETEs come after us
.filter((m2o) => db.entitiesByName[m2o.otherEntity.name].nonDeferredFkOrder > t.nonDeferredFkOrder)
.map((m2o) => `UPDATE ${t.tableName} SET ${m2o.columnName} = NULL;`),
);

const statements = [...setFksNulls, ...deletes].join("\n");

// console.log({ statements });

return `CREATE OR REPLACE FUNCTION flush_database() RETURNS void AS $$
BEGIN
${statements}
Expand Down
2 changes: 2 additions & 0 deletions packages/codegen/src/generateMetadataFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function generateMetadataFile(config: Config, dbMeta: DbMetadata, meta: E
deletedAt: ${q(deletedAt?.fieldName)},
}
`;
const maybeInsertionOrder = meta.nonDeferredFkOrder !== 0 ? `nonDeferredFkOrder: ${meta.nonDeferredFkOrder},` : ``;

return code`
export const ${entity.metaName}: ${EntityMetadata}<${entity.name}> = {
Expand All @@ -62,6 +63,7 @@ export function generateMetadataFile(config: Config, dbMeta: DbMetadata, meta: E
factory: ${imp(`new${entity.name}@./entities.ts`)},
baseTypes: [],
subTypes: [],
${maybeInsertionOrder}
};
(${entity.name} as any).metadata = ${entity.metaName};
Expand Down
50 changes: 38 additions & 12 deletions packages/codegen/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { ConnectionConfig, groupBy, newPgConnectionConfig } from "joist-utils";
import { ConnectionConfig, newPgConnectionConfig } from "joist-utils";
import { Client } from "pg";
import pgStructure from "pg-structure";
import { saveFiles } from "ts-poet";
import { DbMetadata, EntityDbMetadata, failIfOverlappingFieldNames, makeEntity } from "./EntityDbMetadata";
import {
DbMetadata,
EntityDbMetadata,
ManyToOneField,
PolymorphicFieldComponent,
failIfOverlappingFieldNames,
makeEntity,
} from "./EntityDbMetadata";
import { assignTags } from "./assignTags";
import { maybeRunTransforms } from "./codemods";
import { Config, loadConfig, stripStiPlaceholders, warnInvalidConfigEntries, writeConfig } from "./config";
import { generateFiles } from "./generate";
import { createFlushFunction } from "./generateFlushFunction";
import { loadEnumMetadata, loadPgEnumMetadata } from "./loadMetadata";
import { sortByNonDeferredForeignKeys } from "./sortForeignKeys";
import { fail, isEntityTable, isJoinTable, mapSimpleDbTypeToTypescriptType } from "./utils";

export {
Expand Down Expand Up @@ -40,9 +48,19 @@ async function main() {
`Found ${totalTables} total tables, ${entities.length} entity tables, ${Object.entries(enums).length} enum tables`,
);

// Hopefully all FKs are deferred, but if not...
const hasAnyNonDeferredFks = entities.some((e) => e.nonDeferredFks.length > 0);
// Todo: if config.nonDeferredForeignKeys: error, then just stop
if (hasAnyNonDeferredFks) {
sortByNonDeferredForeignKeys(entities);
} else {
for (const entity of entities) entity.nonDeferredFkOrder = 0;
}

await maybeGenerateFlushFunctions(config, client, pgConfig, dbMetadata);
await client.end();

// Apply any codemods to the user's codebase, if we have them
await maybeRunTransforms(config);

// Assign any new tags and write them back to the config file
Expand All @@ -51,17 +69,16 @@ async function main() {
// Do some warnings
for (const entity of entities) failIfOverlappingFieldNames(entity);
warnInvalidConfigEntries(config, dbMetadata);
const loggedFatal = errorOnInvalidDeferredFKs(entities);

// Finally actually generate the files (even if we found a fatal error)
await generateAndSaveFiles(config, dbMetadata);

stripStiPlaceholders(config, entities);
await writeConfig(config);

if (loggedFatal) {
throw new Error("A warning was generated during codegen");
}
// if (loggedFatal) {
// throw new Error("A warning was generated during codegen");
// }
}

/** Uses entities and enums from the `db` schema and saves them into our entities directory. */
Expand Down Expand Up @@ -109,10 +126,11 @@ async function loadSchemaMetadata(config: Config, client: Client): Promise<DbMet
.map((table) => new EntityDbMetadata(config, table, enums));
const totalTables = db.tables.length;
const joinTables = db.tables.filter((t) => isJoinTable(config, t)).map((t) => t.name);
setClassTableInheritance(entities);
const entitiesByName = Object.fromEntries(entities.map((e) => [e.name, e]));
setClassTableInheritance(entities, entitiesByName);
expandSingleTableInheritance(config, entities);
rewriteSingleTableForeignKeys(config, entities);
return { entities, enums, pgEnums, totalTables, joinTables };
return { entities, entitiesByName, enums, pgEnums, totalTables, joinTables };
}

/**
Expand All @@ -121,13 +139,15 @@ async function loadSchemaMetadata(config: Config, client: Client): Promise<DbMet
* (We automatically set `inheritanceType` for STI tables when we see
* their config setup, see `expandSingleTableInheritance`.)
*/
function setClassTableInheritance(entities: EntityDbMetadata[]): void {
const metasByName = groupBy(entities, (e) => e.name);
function setClassTableInheritance(
entities: EntityDbMetadata[],
entitiesByName: Record<string, EntityDbMetadata>,
): void {
const ctiBaseNames: string[] = [];
for (const entity of entities) {
if (entity.baseClassName) {
ctiBaseNames.push(entity.baseClassName);
metasByName[entity.baseClassName]?.[0].subTypes.push(entity);
entitiesByName[entity.baseClassName].subTypes.push(entity);
}
}
for (const entity of entities) {
Expand Down Expand Up @@ -190,7 +210,13 @@ function expandSingleTableInheritance(config: Config, entities: EntityDbMetadata
fail(`No enum row found for ${entity.name}.${fieldName}.${enumCode}`)
).id,
abstract: false,
invalidDeferredFK: 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
Expand Down
Loading

0 comments on commit 74ff45e

Please sign in to comment.