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 29, 2024
1 parent 43187dd commit ce215c6
Show file tree
Hide file tree
Showing 79 changed files with 3,367 additions and 87 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
32 changes: 27 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 @@ -154,8 +156,10 @@ export type ManyToOneField = Field & {
otherEntity: Entity;
notNull: boolean;
isDeferredAndDeferrable: boolean;
constraintName: string;
derived: "async" | false;
hasConfigDefault: boolean;
onDelete: Action;
};

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

export type FieldNameOverrides = {
Expand Down Expand Up @@ -236,7 +243,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 +290,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 +342,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 @@ -558,7 +570,9 @@ function newManyToOneField(config: Config, entity: Entity, r: M2ORelation): Many
derived,
dbType,
isDeferredAndDeferrable,
constraintName: r.foreignKey.name,
hasConfigDefault: hasConfigDefault(config, entity, fieldName),
onDelete: r.foreignKey.onDelete,
};
}

Expand Down Expand Up @@ -637,7 +651,15 @@ 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,
constraintName: r.foreignKey.name,
notNull: false,
};
}

function isFieldNameOverrides(maybeOverride: JSONData | undefined): maybeOverride is FieldNameOverrides {
Expand Down
5 changes: 3 additions & 2 deletions packages/codegen/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,10 @@ export const config = z
ignoredTables: z.optional(z.array(z.string())),
/** The type of entity `id` fields; defaults to `tagged-string`. */
idType: z.optional(z.union([z.literal("tagged-string"), z.literal("untagged-string"), z.literal("number")])),

/** How we should support non-deferred foreign keys. */
nonDeferredForeignKeys: z.optional(z.union([z.literal("error"), z.literal("warn"), z.literal("ignore")])),
/** Enables esm output. */
esm: z.optional(z.boolean()),

// The version of Joist that generated this config.
version: z.string().default("0.0.0"),
})
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

0 comments on commit ce215c6

Please sign in to comment.