@clickup/ent-framework
Version:
A PostgreSQL graph-database-alike library with microsharding and row-level security
858 lines (775 loc) • 24.2 kB
text/typescript
import { collect } from "streaming-iterables";
import { join } from "../../internal/misc";
import { recreateTestTables, testCluster } from "../../pg/__tests__/test-utils";
import { PgSchema } from "../../pg/PgSchema";
import { EnumType, ID } from "../../types";
import { BaseEnt } from "../BaseEnt";
import { CanDeleteOutgoingEdge } from "../predicates/CanDeleteOutgoingEdge";
import { CanReadOutgoingEdge } from "../predicates/CanReadOutgoingEdge";
import { CanUpdateOutgoingEdge } from "../predicates/CanUpdateOutgoingEdge";
import { IncomingEdgeFromVCExists } from "../predicates/IncomingEdgeFromVCExists";
import { OutgoingEdgePointsToVC } from "../predicates/OutgoingEdgePointsToVC";
import { True } from "../predicates/True";
import { AllowIf } from "../rules/AllowIf";
import { Require } from "../rules/Require";
import { GLOBAL_SHARD } from "../ShardAffinity";
import type { VC } from "../VC";
import { createVC, expectToMatchSnapshot } from "./test-utils";
enum Industry {
IT = "it",
HEALTH = "health",
}
enum Size {
ONE = 1,
TWO = 2,
}
/**
* Company
*/
class EntTestCompany extends BaseEnt(
testCluster,
new PgSchema(
'ent.generic"company',
{
id: { type: String, autoInsert: "id_gen()" },
name: { type: EnumType<"some" | "other">() },
industry: { type: EnumType<Industry>() },
size: { type: EnumType<Size>() },
},
["name", "industry"],
),
) {
static readonly CREATE = [
`CREATE TABLE %T(
id bigint NOT NULL PRIMARY KEY,
name text NOT NULL,
industry text NOT NULL,
size integer NOT NULL
)`,
];
static override configure() {
return new this.Configuration({
shardAffinity: GLOBAL_SHARD,
privacyInferPrincipal: null,
privacyLoad: [
new AllowIf(async function VCIsAllSeeing(vc, _company) {
const vcUser = await EntTestUser.loadX(vc, vc.principal);
return vcUser.is_alseeing;
}),
new AllowIf(
new IncomingEdgeFromVCExists(EntTestUser, "id", "company_id"),
),
],
privacyInsert: [],
});
}
}
/**
* User -> Company
*/
class EntTestUser extends BaseEnt(
testCluster,
new PgSchema(
'ent.generic"user',
{
id: { type: ID, autoInsert: "id_gen()" },
company_id: { type: ID, allowNull: true, autoInsert: "NULL" },
name: { type: String },
url_name: { type: String, allowNull: true },
is_alseeing: { type: Boolean, autoInsert: "false" },
created_at: { type: Date, autoInsert: "now()" },
updated_at: { type: Date, autoUpdate: "now()" },
},
["name"],
),
) {
static readonly CREATE = [
`CREATE TABLE %T(
id bigint NOT NULL PRIMARY KEY,
company_id bigint DEFAULT NULL,
name text NOT NULL,
url_name text,
is_alseeing boolean,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
UNIQUE (name)
)`,
];
some!: number;
static override configure() {
return new this.Configuration({
shardAffinity: GLOBAL_SHARD,
privacyInferPrincipal: async (_vc, row) => row.id,
privacyLoad: [
new AllowIf(new OutgoingEdgePointsToVC("id")),
new AllowIf(new CanReadOutgoingEdge("company_id", EntTestCompany)),
],
privacyInsert: [],
privacyUpdate: [new Require(new OutgoingEdgePointsToVC("id"))],
});
}
nameUpper(): string {
return this.name.toUpperCase();
}
}
/**
* Post -> User -> Company
*/
class EntTestPost extends BaseEnt(
testCluster,
new PgSchema(
'ent.generic"post',
{
post_id: { type: ID, autoInsert: "id_gen()" },
user_id: { type: ID },
title: { type: String },
created_at: { type: Date, autoInsert: "now()" },
},
["post_id"],
),
) {
static readonly CREATE = [
`CREATE TABLE %T(
post_id bigint NOT NULL PRIMARY KEY,
user_id bigint NOT NULL,
title text NOT NULL,
created_at timestamptz NOT NULL
)`,
];
static override configure() {
return new this.Configuration({
shardAffinity: ["post_id"],
privacyInferPrincipal: async (_vc, row) => row.user_id,
privacyLoad: [
new AllowIf(new CanReadOutgoingEdge("user_id", EntTestUser)),
],
privacyInsert: [
new Require(new CanUpdateOutgoingEdge("user_id", EntTestUser)),
],
privacyUpdate: [
new Require(async function VCInSameCompany(vc, post) {
// A post can be updated by anyone in the same company.
const postUser = await EntTestUser.loadX(vc, post.user_id);
const vcUser = await EntTestUser.loadX(vc, vc.principal);
return postUser.company_id === vcUser.company_id;
}),
],
});
}
titleUpper(): string {
return this.title.toUpperCase();
}
async user(): Promise<EntTestUser> {
return EntTestUser.loadX(this.vc, this.user_id);
}
}
/**
* Comment -> Post -> User -> Company
*/
class EntTestComment extends BaseEnt(
testCluster,
new PgSchema(
'ent.generic"comment',
{
comment_id: { type: String, autoInsert: "id_gen()" },
post_id: { type: ID },
text: { type: String },
},
["comment_id"],
),
) {
static readonly CREATE = [
`CREATE TABLE %T(
comment_id bigint NOT NULL PRIMARY KEY,
post_id bigint NOT NULL,
text text NOT NULL
)`,
];
static override configure() {
return new this.Configuration({
shardAffinity: ["post_id"],
privacyInferPrincipal: async (vc, row) =>
EntTestPost.loadX(vc, row.post_id),
privacyLoad: [
new AllowIf(new CanReadOutgoingEdge("post_id", EntTestPost)),
],
privacyInsert: [
new Require(new CanUpdateOutgoingEdge("post_id", EntTestPost)),
],
privacyDelete: [
new Require(new CanDeleteOutgoingEdge("post_id", EntTestPost)),
],
});
}
textUpper(): string {
return this.text.toUpperCase();
}
}
/**
* Like -> Post -> User -> Company
*/
class EntTestLike extends BaseEnt(
testCluster,
new PgSchema(
'ent.generic"like',
{
id: { type: ID, autoInsert: "id_gen()" },
post_id: { type: ID },
user_id: { type: ID },
},
["post_id", "user_id"],
),
) {
static readonly CREATE = [
`CREATE TABLE %T(
id bigint NOT NULL PRIMARY KEY,
post_id bigint NOT NULL,
user_id bigint NOT NULL
)`,
];
static override configure() {
return new this.Configuration({
shardAffinity: ["post_id"],
privacyInferPrincipal: async (_vc, row) => row.user_id,
privacyLoad: [
new AllowIf(new CanReadOutgoingEdge("post_id", EntTestPost)),
],
privacyInsert: [
new Require(new CanUpdateOutgoingEdge("post_id", EntTestPost)),
],
});
}
}
let vc: VC;
let vcOther: VC;
beforeEach(async () => {
await recreateTestTables([
EntTestCompany,
EntTestUser,
EntTestPost,
EntTestComment,
EntTestLike,
]);
const company = await EntTestCompany.insertReturning(
createVC().toOmniDangerous(),
{ name: "some", industry: Industry.IT, size: Size.ONE },
);
expect(company.name).toEqual("some");
expect(company.industry).toEqual(Industry.IT);
expect(company.size).toEqual(Size.ONE);
const user = await EntTestUser.insertReturning(company.vc.toOmniDangerous(), {
company_id: company.id,
name: "John",
url_name: "john",
});
expect(user.vc.principal).toEqual(user.id);
vc = user.vc;
const otherUser = await EntTestUser.insertReturning(
company.vc.toOmniDangerous(),
{ name: Date.now().toString(), url_name: "" },
);
vcOther = otherUser.vc;
});
test("simple use case", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
expect(user.url_name).toEqual("john");
const post = await EntTestPost.insertReturning(vc, {
user_id: user.id,
title: "something",
});
expect(post.created_at).toBeInstanceOf(Date);
const loadedUser = await post.user();
expect(loadedUser.name).toEqual(user.name);
});
test("loadX", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
expect(user.nameUpper()).toEqual("JOHN");
});
test("loadX coalescing produces same objects", async () => {
const [user1, user2] = await join([
EntTestUser.loadX(vc, vc.principal),
EntTestUser.loadX(vc, vc.principal),
]);
user1.some = 10;
expect(user2.some).toEqual(10);
});
test("loadX coalescing produces different objects for different vc", async () => {
const [user1, user2] = await join([
EntTestUser.loadX(vc, vc.principal),
EntTestUser.loadX(vc.toOmniDangerous(), vc.principal),
]);
user1.some = 10;
expect(user2.some).toBeUndefined();
});
test("loadNullable with no access", async () => {
try {
await EntTestUser.loadNullable(vcOther, vc.principal);
fail("must throw an exception");
} catch (e: unknown) {
expectToMatchSnapshot("" + e);
}
});
test("load child with no access", async () => {
const post = await EntTestPost.insertReturning(vc, {
user_id: vc.principal,
title: "some_post",
});
const comment = await EntTestComment.insertReturning(vc, {
post_id: post.id,
text: "some_comment",
});
try {
await EntTestComment.loadNullable(vcOther, comment.id);
fail("must throw an exception");
} catch (e: unknown) {
expectToMatchSnapshot("" + e);
}
});
test("loadByX", async () => {
const user = await EntTestUser.loadByX(vc, { name: "John" });
expect(user.url_name).toEqual("john");
expect(user.nameUpper()).toEqual("JOHN");
expect(await EntTestUser.loadByNullable(vc, { name: "zzz" })).toBeNull();
});
test("loadBy with no access", async () => {
try {
await EntTestUser.loadByX(vcOther, { name: "John" });
fail("must throw an exception");
} catch (e: unknown) {
expectToMatchSnapshot("" + e);
}
});
test("selectBy", async () => {
const post = await EntTestPost.insertReturning(vc, {
user_id: vc.principal,
title: "some_post",
});
const like = await EntTestLike.insertReturning(vc, {
post_id: post.id,
user_id: vc.principal,
});
const likes = await EntTestLike.selectBy(vc, {
post_id: post.id,
});
expect(likes.map((ent) => ent.id)).toEqual([like.id]);
});
test("select and count", async () => {
const post = await EntTestPost.insertReturning(vc, {
user_id: vc.principal,
title: "post",
});
await join([
EntTestComment.insertReturning(vc, { post_id: post.id, text: "c2" }),
EntTestComment.insertReturning(vc, { post_id: post.id, text: "c3" }),
]);
const comments = await EntTestComment.select(
vc,
{ post_id: post.id, text: ["c1", "c2", "c3"] },
2,
[{ text: "DESC" }],
);
expect(comments.length).toEqual(2);
expect(comments[0].textUpper()).toEqual("C3");
expect(comments[1].textUpper()).toEqual("C2");
const comments2 = await EntTestComment.select(
vc,
{ id: [comments[0].id, comments[1].id] },
2,
[{ text: "DESC" }],
);
expect(comments2.length).toEqual(2);
const error1 = await EntTestComment.select(
vc,
{ post_id: [] },
Number.MAX_SAFE_INTEGER,
).catch((e) => e.toString());
expect(error1).toMatchSnapshot();
const error2 = await EntTestComment.select(
vc,
{ post_id: "" },
Number.MAX_SAFE_INTEGER,
).catch((e) => e.toString());
expect(error2).toMatchSnapshot();
const count = await EntTestComment.count(vc, {
post_id: post.id,
text: ["c1", "c2", "c3"],
});
expect(count).toEqual(2);
const exists1 = await EntTestComment.exists(vc, {
post_id: post.id,
text: ["c1", "c2", "c3"],
});
expect(exists1).toStrictEqual(true);
const exists2 = await EntTestComment.exists(vc, {
post_id: post.id,
text: ["cNonExistent"],
});
expect(exists2).toStrictEqual(false);
});
test("selectChunked", async () => {
const post = await EntTestPost.insertReturning(vc, {
user_id: vc.principal,
title: "post",
});
await join([
EntTestComment.insertReturning(vc, { post_id: post.id, text: "c2" }),
EntTestComment.insertReturning(vc, { post_id: post.id, text: "c3" }),
EntTestComment.insertReturning(vc, { post_id: post.id, text: "c4" }),
EntTestComment.insertReturning(vc, { post_id: post.id, text: "c5" }),
]);
const commentChunks = await collect(
EntTestComment.selectChunked(
vc,
{ post_id: post.id, $not: { text: "c4" } },
2,
Number.MAX_SAFE_INTEGER,
),
);
expect(commentChunks.length).toEqual(2);
expect(commentChunks[0].length).toEqual(2);
expect(commentChunks[1].length).toEqual(1);
const noChunks = await collect(
EntTestComment.selectChunked(
vc,
{ post_id: post.id, text: "nothing" },
2,
Number.MAX_SAFE_INTEGER,
),
);
expect(noChunks).toEqual([]);
});
test("custom shard", async () => {
const post = await EntTestPost.insertReturning(vc, {
user_id: vc.principal,
title: "something",
});
const idInOtherNonGlobalShard = post.id.replace(
/^(\d0+)(\d)/s,
(_, m1, m2) => m1 + (((parseInt(m2) - 1 + 1) % 2) + 1).toString(),
);
const posts = await EntTestPost.select(
vc,
{ id: post.id, $shardOfID: idInOtherNonGlobalShard },
Number.MAX_SAFE_INTEGER,
);
expect(posts).toHaveLength(0);
const error = await EntTestPost.select(
vc,
{ id: post.id, $shardOfID: "" },
Number.MAX_SAFE_INTEGER,
).catch((e) => e.toString());
expect(error).toMatchSnapshot();
});
test("upsertReturning overwrites", async () => {
const user = await EntTestUser.upsertReturning(vc.toOmniDangerous(), {
name: "John",
url_name: "new_value",
});
expect(user).toMatchObject({ name: "John", url_name: "new_value" });
});
test("upsertReturning creates new ent", async () => {
const newUser = await EntTestUser.upsertReturning(vc.toOmniDangerous(), {
name: "Someone",
url_name: "someone",
});
expect(newUser).toMatchObject({ name: "Someone", url_name: "someone" });
expect(newUser.vc.principal).toEqual(newUser.id);
});
test("updateReturningX", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
const newUser = await user.updateReturningX({ url_name: "new" });
expect(newUser).toMatchObject({ name: "John", url_name: "new" });
expect(newUser.nameUpper()).toEqual("JOHN");
});
test("updateChanged ignores $literal when all fields are unchanged", async () => {
let user = await EntTestUser.loadX(vc, vc.principal);
expect(
await user.updateChanged({
url_name: "john",
$literal: ["url_name=?", "new"],
}),
).toStrictEqual(null);
user = await EntTestUser.loadX(vc, vc.principal);
expect(user).toMatchObject({ name: "John", url_name: "john" });
expect(
await user.updateChanged({
$literal: ["url_name=?", "new"],
}),
).toStrictEqual(null);
user = await EntTestUser.loadX(vc, vc.principal);
expect(user).toMatchObject({ name: "John", url_name: "john" });
});
test("updateChanged applies $literal when there are changed fields", async () => {
let user = await EntTestUser.loadX(vc, vc.principal);
expect(
await user.updateChanged({
name: "Doe",
$literal: ["url_name=?", "new"],
}),
).toStrictEqual(["name"]);
user = await EntTestUser.loadX(vc, vc.principal);
expect(user).toMatchObject({ name: "Doe", url_name: "new" });
});
test("updateChangedReturningX", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
const newUser1 = await user.updateChangedReturningX({ url_name: "new" });
expect(newUser1).toMatchObject({ name: "John", url_name: "new" });
expect(newUser1 === user).toBeFalsy();
const newUser2 = await newUser1.updateChangedReturningX({ url_name: "new" });
expect(newUser2 === newUser1).toBeTruthy();
});
test("updateOriginal with CAS", async () => {
// There are way more tests for CAS in pg/__tests__, with all corner cases
// covered; here we just illustrate the basic syntax.
const user = await EntTestUser.loadX(vc, vc.principal);
expect(
await user.updateOriginal({
url_name: "skip",
$cas: { updated_at: new Date(42) },
}),
).toBeFalsy();
expect(
await user.updateOriginal({
url_name: "new",
$cas: { updated_at: user.updated_at },
}),
).toBeTruthy();
expect(
await user.updateOriginal({
url_name: "skip2",
$cas: ["updated_at"],
}),
).toBeFalsy();
let newUser = await EntTestUser.loadX(vc, vc.principal);
expect(newUser).toMatchObject({ url_name: "new" });
expect(
await newUser.updateOriginal({
url_name: "newest",
$cas: "skip-if-someone-else-changed-updating-ent-props",
}),
).toBeTruthy();
newUser = await EntTestUser.loadX(vc, vc.principal);
expect(
await newUser.updateOriginal({
url_name: "newest",
$cas: ["updated_at"],
}),
).toBeTruthy();
});
test("updateChanged with CAS", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
expect(
await user.updateChanged({
url_name: "skip-by-cas",
$cas: { updated_at: new Date(42) },
}),
).toStrictEqual(false);
expect(
await user.updateChanged({
url_name: user.url_name, // skipped since no fields are changed
$cas: { updated_at: new Date(42) }, // CAS doesn't matter
}),
).toStrictEqual(null);
expect(
await user.updateChanged({
url_name: user.url_name, // skipped since no fields are changed
$cas: ["updated_at"], // CAS doesn't matter
}),
).toStrictEqual(null);
expect(
await user.updateChanged({
url_name: "new", // field changed
$cas: ["updated_at"], // CAS succeeded
}),
).toStrictEqual(["url_name"]);
});
test("updateChangedReturningX with CAS", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
const original = { ...user };
expect(
await user.updateChangedReturningX({
url_name: "skip-by-cas",
$cas: { updated_at: new Date(42) },
}),
).toMatchObject({ url_name: original.url_name });
expect(
await user.updateChangedReturningX({
url_name: user.url_name, // skipped since no fields are changed
$cas: { updated_at: new Date(42) }, // CAS doesn't matter
}),
).toMatchObject({
url_name: original.url_name,
});
expect(
await user.updateChangedReturningX({
url_name: user.url_name, // skipped since no fields are changed
$cas: { updated_at: user.updated_at }, // CAS doesn't matter
}),
).toMatchObject({
url_name: original.url_name,
});
expect(
await user.updateChangedReturningX({
url_name: "new", // field changed
$cas: { updated_at: user.updated_at }, // CAS succeeded
}),
).toMatchObject({
url_name: "new",
});
});
test("delete", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
expect(await user.deleteOriginal()).toBeTruthy();
expect(await EntTestUser.loadNullable(vc, vc.principal)).toBeNull();
expect(await user.deleteOriginal()).toBeFalsy();
});
test("can read post of the same company user", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
const post = await EntTestPost.insertReturning(vc, {
user_id: user.id,
title: "something",
});
const user2 = await EntTestUser.insertReturning(vc.toOmniDangerous(), {
company_id: user.company_id,
name: "Jane",
url_name: "jane",
});
const post2 = await EntTestPost.loadX(user2.vc, post.id);
expect(post2.title).toEqual("something");
});
test("can update comment of the same company user", async () => {
const user = await EntTestUser.loadX(vc, vc.principal);
const post = await EntTestPost.insertReturning(vc, {
user_id: user.id,
title: "something",
});
const comment = await EntTestComment.insertReturning(vc, {
post_id: post.id,
text: "some",
});
// Add user to the company.
const user2 = await EntTestUser.insertReturning(vc.toOmniDangerous(), {
company_id: user.company_id,
name: "Jane",
url_name: "jane",
});
// Check that this user can update comments of other same-company users.
const commentViaUser2 = await EntTestComment.loadX(user2.vc, comment.id);
await commentViaUser2.updateOriginal({ text: "other" });
await commentViaUser2.deleteOriginal();
});
test("cannot create posts for different users", async () => {
const userAllseeing = await EntTestUser.insertReturning(
vc.toOmniDangerous(),
{ is_alseeing: true, name: "All-seeing", url_name: "all-seeing" },
);
try {
await EntTestPost.insertReturning(userAllseeing.vc, {
user_id: vc.principal,
title: "something",
});
fail("must throw an exception");
} catch (e: unknown) {
expectToMatchSnapshot("" + e);
}
});
test("heisenbug: two different schema field sets make schema hash different", async () => {
const schema1 = new PgSchema(
EntTestUser.SCHEMA.name,
{
id: { type: ID, autoInsert: "id_gen()" },
company_id: { type: ID, allowNull: true, autoInsert: "NULL" },
},
[],
);
const schema2 = new PgSchema(
EntTestUser.SCHEMA.name,
{
id: { type: ID, autoInsert: "id_gen()" },
name: { type: String },
},
[],
);
class Ent1 extends BaseEnt(testCluster, schema1) {
static override configure() {
return new this.Configuration({
shardAffinity: GLOBAL_SHARD,
privacyInferPrincipal: async (_vc, row) => row.id,
privacyLoad: [new AllowIf(new True())],
privacyInsert: [],
privacyUpdate: [new Require(new True())],
});
}
}
class Ent2 extends BaseEnt(testCluster, schema2) {
company_id!: string;
static override configure() {
return new this.Configuration({
shardAffinity: GLOBAL_SHARD,
privacyInferPrincipal: async (_vc, row) => row.id,
privacyLoad: [new AllowIf(new True())],
privacyInsert: [],
privacyUpdate: [new Require(new True())],
});
}
}
const row1 = await Ent1.loadX(vc, vc.principal);
const row2 = await Ent2.loadX(vc, vc.principal);
expect(row1.company_id).toBeTruthy();
expect(row2.company_id).toBeUndefined(); // heisenbug was here
});
test("attempt to use guest VC to load Ents", async () => {
const fakeNull = null as unknown as string;
await expect(
EntTestPost.loadNullable(vc.toGuest(), vc.toGuest().principal),
).rejects.toThrowErrorMatchingSnapshot();
await expect(
EntTestPost.loadX(vc.toGuest(), vc.toGuest().principal),
).rejects.toThrowErrorMatchingSnapshot();
await expect(
EntTestPost.select(vc.toGuest(), { post_id: vc.toGuest().principal }, 1),
).rejects.toThrowErrorMatchingSnapshot();
await expect(
EntTestPost.select(vc.toGuest(), { post_id: fakeNull }, 1),
).rejects.toThrowErrorMatchingSnapshot();
await expect(
EntTestPost.loadNullable(vc.toGuest(), fakeNull),
).rejects.toThrowErrorMatchingSnapshot();
});
test("write without affecting timeline", async () => {
const preInsertTimeline = vc.serializeTimelines();
const vcDerived = vc.withOneTimeStaleReplica();
await EntTestPost.insertReturning(vcDerived, {
user_id: vc.principal,
title: "some_post",
});
expect(vcDerived.serializeTimelines()).toEqual(preInsertTimeline);
expect(vc.serializeTimelines()).toEqual(preInsertTimeline);
});
test("should support inserting simple Ents with custom IDs", async () => {
const ent = await EntTestUser.insertReturning(vc.toOmniDangerous(), {
name: "Test",
url_name: null,
});
await ent.deleteOriginal();
const newEnt = await EntTestUser.insertReturning(vc.toOmniDangerous(), ent);
expect(newEnt.id).toEqual(ent.id);
});
test("delete orphaned comment", async () => testCommentDeletion(true));
test("delete comment", async () => testCommentDeletion(false));
async function testCommentDeletion(
makeCommentOrphaned: boolean,
): Promise<void> {
const post = await EntTestPost.insertReturning(vc, {
user_id: vc.principal,
title: "some_post",
});
const comment = await EntTestComment.insertReturning(vc, {
post_id: post.id,
text: "some_comment",
});
if (makeCommentOrphaned) {
await post.deleteOriginal();
}
await comment.deleteOriginal();
}