Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support schemas with non-deferred FKs. #1100

Merged
merged 2 commits into from
May 30, 2024
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
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 { ... }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😱

}
```

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
Loading
Loading