Skip to content

Commit

Permalink
fix: always reload entity after update since cascading changes may ha…
Browse files Browse the repository at this point in the history
…ve changed it since commit
  • Loading branch information
wschurman committed Jun 3, 2024
1 parent af495a9 commit 3e01a92
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
UUIDField,
} from '@expo/entity';
import { GenericRedisCacheContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
import { knex, Knex } from 'knex';
import nullthrows from 'nullthrows';
import { URL } from 'url';
import { v4 as uuidv4 } from 'uuid';

import { createFullIntegrationTestEntityCompanionProvider } from '../testfixtures/createFullIntegrationTestEntityCompanionProvider';

interface TestFields {
id: string;
}

class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
string,
ViewerContext,
TestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, string, ViewerContext, TestEntity>(),
];
}

class TestEntity extends Entity<TestFields, string, ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestFields,
string,
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy
> {
return {
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
};
}
}

const testEntityConfiguration = new EntityConfiguration<TestFields>({
idField: 'id',
tableName: 'testentities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});

async function createOrTruncatePostgresTables(knex: Knex): Promise<void> {
await knex.schema.createTable('testentities', (table) => {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
});
await knex.into('testentities').truncate();
}

async function dropPostgresTable(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('testentities')) {
await knex.schema.dropTable('testentities');
}
}

describe('Entity integrity', () => {
let knexInstance: Knex;
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
let genericRedisCacheContext: GenericRedisCacheContext;

beforeAll(() => {
knexInstance = knex({
client: 'pg',
connection: {
user: nullthrows(process.env['PGUSER']),
password: nullthrows(process.env['PGPASSWORD']),
host: 'localhost',
port: parseInt(nullthrows(process.env['PGPORT']), 10),
database: nullthrows(process.env['PGDATABASE']),
},
});
genericRedisCacheContext = {
redisClient,
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
cacheKeyPrefix: 'test-',
ttlSecondsPositive: 86400, // 1 day
ttlSecondsNegative: 600, // 10 minutes
};
});

beforeEach(async () => {
await createOrTruncatePostgresTables(knexInstance);
await redisClient.flushdb();
});

afterAll(async () => {
await dropPostgresTable(knexInstance);
await knexInstance.destroy();
redisClient.disconnect();
});

test('cannot update ID', async () => {
const viewerContext = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
);

const entity1 = await TestEntity.creator(viewerContext).enforceCreateAsync();

await expect(
TestEntity.updater(entity1).setField('id', uuidv4()).enforceUpdateAsync()
).rejects.toThrow('id field updates not supported: (entityClass = TestEntity)');

// ensure cache consistency
const viewerContextLast = new ViewerContext(
createFullIntegrationTestEntityCompanionProvider(knexInstance, genericRedisCacheContext)
);

const loadedById = await TestEntity.loader(viewerContextLast)
.enforcing()
.loadByIDAsync(entity1.getID());

expect(loadedById.getID()).toEqual(entity1.getID());
});
});
35 changes: 20 additions & 15 deletions packages/entity/src/EntityMutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export class UpdateMutator<
cascadingDeleteCause: EntityCascadingDeletionInfo | null
): Promise<Result<TEntity>> {
this.validateFields(this.updatedFields);
this.ensureStableIDField(this.updatedFields);

const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
previousValue: this.originalEntity,
Expand Down Expand Up @@ -462,14 +463,14 @@ export class UpdateMutator<
);

// skip the database update when specified
const updateResult = skipDatabaseUpdate
? null
: await this.databaseAdapter.updateAsync(
queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields
);
if (!skipDatabaseUpdate) {
await this.databaseAdapter.updateAsync(
queryContext,
this.entityConfiguration.idField,
entityAboutToBeUpdated.getID(),
this.updatedFields
);
}

queryContext.appendPostCommitInvalidationCallback(
entityLoader.invalidateFieldsAsync.bind(
Expand All @@ -481,13 +482,9 @@ export class UpdateMutator<
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
);

// when the database update was skipped, assume it succeeded and use the optimistic
// entity to execute triggers
const updatedEntity = updateResult
? await entityLoader
.enforcing()
.loadByIDAsync(entityLoader.constructEntity(updateResult).getID())
: entityAboutToBeUpdated;
const updatedEntity = await entityLoader
.enforcing()
.loadByIDAsync(entityAboutToBeUpdated.getID()); // ID is guaranteed to be stable by ensureStableIDField

await this.executeMutationTriggersAsync(
this.mutationTriggers.afterUpdate,
Expand Down Expand Up @@ -517,6 +514,14 @@ export class UpdateMutator<

return result(updatedEntity);
}

private ensureStableIDField(updatedFields: Partial<TFields>): void {
const originalId = this.originalEntity.getID();
const idField = this.entityConfiguration.idField;
if (idField in updatedFields && originalId !== updatedFields[idField]) {
throw new Error(`id field updates not supported: (entityClass = ${this.entityClass.name})`);
}
}
}

/**
Expand Down
49 changes: 49 additions & 0 deletions packages/entity/src/__tests__/EntityMutator-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,7 @@ describe(EntityMutatorFactory, () => {
}
);
});

it('executes validators', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(
Expand Down Expand Up @@ -771,6 +772,54 @@ describe(EntityMutatorFactory, () => {
cascadingDeleteCause: null,
});
});

it('throws when id field is updated', async () => {
const viewerContext = mock<ViewerContext>();
const privacyPolicyEvaluationContext = instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestFields,
string,
ViewerContext,
TestEntity,
keyof TestFields
>
>()
);
const queryContext = StubQueryContextProvider.getQueryContext();

const id1 = uuidv4();
const { entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory([
{
customIdField: id1,
stringField: 'huh',
testIndexedField: '4',
intField: 3,
dateField: new Date(),
nullableField: null,
},
]);

const existingEntity = await enforceAsyncResult(
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
);

await expect(
entityMutatorFactory
.forUpdate(existingEntity, queryContext)
.setField('customIdField', uuidv4())
.enforceUpdateAsync()
).rejects.toThrow('id field updates not supported: (entityClass = TestEntity)');

const reloadedEntity = await enforceAsyncResult(
entityLoaderFactory
.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext)
.loadByIDAsync(id1)
);
expect(reloadedEntity.getAllFields()).toMatchObject(existingEntity.getAllFields());
});
});

describe('forDelete', () => {
Expand Down

0 comments on commit 3e01a92

Please sign in to comment.