From 9afd01f8d605dd0a9e62c138ad17f5c62291fe4c Mon Sep 17 00:00:00 2001 From: Dave New Date: Mon, 30 Oct 2023 18:33:45 +0200 Subject: [PATCH] fix: updatedat field not updating on mutations (#1271) --- .prettierrc | 3 ++ .../testdata/built_in_actions/main.test.ts | 28 ++++++++++++++ .../with_custom_function_hooks/main.test.ts | 38 +++++++++++++++++++ migrations/migrations.go | 11 ++++++ migrations/set_updated_at.sql | 6 +++ migrations/sql.go | 15 ++++++++ .../testdata/composite_unique_initial.txt | 2 + migrations/testdata/default_value_initial.txt | 2 + migrations/testdata/initial.txt | 2 + migrations/testdata/model_added.txt | 2 + migrations/testdata/simple_field_types.txt | 2 + packages/testing-runtime/src/Executor.mjs | 13 ++++++- runtime/runtime_events_test.go | 3 +- 13 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 .prettierrc create mode 100644 migrations/set_updated_at.sql diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..1b016bffe --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "trailingComma": "es5" +} \ No newline at end of file diff --git a/integration/testdata/built_in_actions/main.test.ts b/integration/testdata/built_in_actions/main.test.ts index f50d4b389..f57b0765d 100644 --- a/integration/testdata/built_in_actions/main.test.ts +++ b/integration/testdata/built_in_actions/main.test.ts @@ -247,6 +247,30 @@ test("update action", async () => { expect(updatedPost.subTitle).toEqual("opm"); }); +test("update action - updatedAt set", async () => { + const post = await models.post.create({ + title: "watermelon", + subTitle: "opm", + }); + + expect(post.updatedAt).not.toBeNull(); + expect(post.updatedAt).toEqual(post.createdAt); + + await delay(100); + + const updatedPost = await actions.updatePost({ + where: { id: post.id }, + values: { title: "big watermelon" }, + }); + + expect(updatedPost.updatedAt.valueOf()).toBeGreaterThanOrEqual( + post.createdAt.valueOf() + 100 + ); + expect(updatedPost.updatedAt.valueOf()).toBeLessThan( + post.createdAt.valueOf() + 1000 + ); +}); + test("update action - explicit set / args", async () => { const post = await models.post.create({ title: "watermelon", @@ -260,3 +284,7 @@ test("update action - explicit set / args", async () => { expect(updatedPost.title).toEqual("a really cool title"); }); + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/integration/testdata/with_custom_function_hooks/main.test.ts b/integration/testdata/with_custom_function_hooks/main.test.ts index 0188296ab..d389633cf 100644 --- a/integration/testdata/with_custom_function_hooks/main.test.ts +++ b/integration/testdata/with_custom_function_hooks/main.test.ts @@ -86,6 +86,44 @@ describe("write hooks", () => { }) ).toHaveAuthorizationError(); }); + + test("update.beforeWrite hook - updatedAt set", async () => { + const identity = await models.identity.create({ + email: "adam@keel.xyz", + }); + const name = "Alice"; + + const person = await models.person.create({ + sex: Sex.Female, + title: name, + }); + + expect(person.updatedAt).not.toBeNull(); + expect(person.updatedAt).toEqual(person.createdAt); + + await delay(100); + + const record = await actions + .withIdentity(identity) + .updatePersonWithBeforeWrite({ + where: { id: person.id }, + values: { + title: "Alice", + sex: Sex.Female, + }, + }); + + expect(record.updatedAt.valueOf()).toBeGreaterThanOrEqual( + person.createdAt.valueOf() + 100 + ); + expect(record.updatedAt.valueOf()).toBeLessThan( + person.createdAt.valueOf() + 1000 + ); + }); + + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } }); describe("query hooks", () => { diff --git a/migrations/migrations.go b/migrations/migrations.go index 1b5098081..64299a751 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -41,6 +41,9 @@ var ( //go:embed set_trace_id.sql setTraceId string + + //go:embed set_updated_at.sql + setUpdatedAt string ) type DatabaseChange struct { @@ -95,6 +98,8 @@ func (m *Migrations) Apply(ctx context.Context) error { sql.WriteString("\n") sql.WriteString(setTraceId) sql.WriteString("\n") + sql.WriteString(setUpdatedAt) + sql.WriteString("\n") sql.WriteString("CREATE TABLE IF NOT EXISTS keel_schema ( schema TEXT NOT NULL );\n") sql.WriteString("DELETE FROM keel_schema;\n") @@ -223,6 +228,12 @@ func New(ctx context.Context, schema *proto.Schema, database db.Database) (*Migr return nil, err } statements = append(statements, stmt) + + stmt, err = createUpdatedAtTriggerStmts(triggers, schema, model) + if err != nil { + return nil, err + } + statements = append(statements, stmt) } } diff --git a/migrations/set_updated_at.sql b/migrations/set_updated_at.sql new file mode 100644 index 000000000..e49e2e294 --- /dev/null +++ b/migrations/set_updated_at.sql @@ -0,0 +1,6 @@ +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/migrations/sql.go b/migrations/sql.go index 3a07fc4b5..871088a3b 100644 --- a/migrations/sql.go +++ b/migrations/sql.go @@ -338,3 +338,18 @@ func createAuditTriggerStmts(triggers []*TriggerRow, schema *proto.Schema, model return strings.Join(statements, "\n"), nil } + +// createUpdatedAtTriggerStmts generates the CREATE TRIGGER statements for automatically updating each model's updatedAt column. +// Only creates a trigger if the trigger does not already exist in the database. +func createUpdatedAtTriggerStmts(triggers []*TriggerRow, schema *proto.Schema, model *proto.Model) (string, error) { + modelLower := casing.ToSnake(model.Name) + statements := []string{} + + updatedAt := fmt.Sprintf("%s_updated_at", modelLower) + if _, found := lo.Find(triggers, func(t *TriggerRow) bool { return t.TriggerName == updatedAt && t.TableName == modelLower }); !found { + statements = append(statements, fmt.Sprintf( + `CREATE TRIGGER %s BEFORE UPDATE ON %s FOR EACH ROW EXECUTE PROCEDURE set_updated_at();`, updatedAt, Identifier(model.Name))) + } + + return strings.Join(statements, "\n"), nil +} diff --git a/migrations/testdata/composite_unique_initial.txt b/migrations/testdata/composite_unique_initial.txt index eaa557596..571a387da 100644 --- a/migrations/testdata/composite_unique_initial.txt +++ b/migrations/testdata/composite_unique_initial.txt @@ -50,9 +50,11 @@ ALTER TABLE "person" ADD CONSTRAINT person_thing_a_thing_b_thing_c_udx UNIQUE (" CREATE TRIGGER person_create AFTER INSERT ON "person" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER person_update AFTER UPDATE ON "person" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER person_delete AFTER DELETE ON "person" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER person_updated_at BEFORE UPDATE ON "person" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); CREATE TRIGGER identity_create AFTER INSERT ON "identity" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER identity_update AFTER UPDATE ON "identity" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER identity_delete AFTER DELETE ON "identity" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER identity_updated_at BEFORE UPDATE ON "identity" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); === diff --git a/migrations/testdata/default_value_initial.txt b/migrations/testdata/default_value_initial.txt index 96afdefc8..fd71ffac2 100644 --- a/migrations/testdata/default_value_initial.txt +++ b/migrations/testdata/default_value_initial.txt @@ -60,9 +60,11 @@ ALTER TABLE "person" ADD CONSTRAINT person_id_pkey PRIMARY KEY ("id"); CREATE TRIGGER person_create AFTER INSERT ON "person" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER person_update AFTER UPDATE ON "person" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER person_delete AFTER DELETE ON "person" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER person_updated_at BEFORE UPDATE ON "person" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); CREATE TRIGGER identity_create AFTER INSERT ON "identity" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER identity_update AFTER UPDATE ON "identity" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER identity_delete AFTER DELETE ON "identity" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER identity_updated_at BEFORE UPDATE ON "identity" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); === diff --git a/migrations/testdata/initial.txt b/migrations/testdata/initial.txt index 3aca031da..888a0193d 100644 --- a/migrations/testdata/initial.txt +++ b/migrations/testdata/initial.txt @@ -41,9 +41,11 @@ ALTER TABLE "person" ADD CONSTRAINT person_id_pkey PRIMARY KEY ("id"); CREATE TRIGGER person_create AFTER INSERT ON "person" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER person_update AFTER UPDATE ON "person" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER person_delete AFTER DELETE ON "person" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER person_updated_at BEFORE UPDATE ON "person" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); CREATE TRIGGER identity_create AFTER INSERT ON "identity" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER identity_update AFTER UPDATE ON "identity" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER identity_delete AFTER DELETE ON "identity" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER identity_updated_at BEFORE UPDATE ON "identity" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); === diff --git a/migrations/testdata/model_added.txt b/migrations/testdata/model_added.txt index d83d72921..00eff4e7a 100644 --- a/migrations/testdata/model_added.txt +++ b/migrations/testdata/model_added.txt @@ -31,9 +31,11 @@ CREATE TABLE "animal" ( ALTER TABLE "animal" ADD CONSTRAINT animal_id_pkey PRIMARY KEY ("id"); ALTER TABLE "animal" ADD FOREIGN KEY ("human_friend_id") REFERENCES "person"("id") ON DELETE CASCADE; + CREATE TRIGGER animal_create AFTER INSERT ON "animal" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER animal_update AFTER UPDATE ON "animal" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER animal_delete AFTER DELETE ON "animal" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER animal_updated_at BEFORE UPDATE ON "animal" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); === diff --git a/migrations/testdata/simple_field_types.txt b/migrations/testdata/simple_field_types.txt index 58f70aa42..ef1eade0b 100644 --- a/migrations/testdata/simple_field_types.txt +++ b/migrations/testdata/simple_field_types.txt @@ -55,9 +55,11 @@ ALTER TABLE "person" ADD CONSTRAINT person_id_pkey PRIMARY KEY ("id"); CREATE TRIGGER person_create AFTER INSERT ON "person" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER person_update AFTER UPDATE ON "person" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER person_delete AFTER DELETE ON "person" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER person_updated_at BEFORE UPDATE ON "person" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); CREATE TRIGGER identity_create AFTER INSERT ON "identity" REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER identity_update AFTER UPDATE ON "identity" REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); CREATE TRIGGER identity_delete AFTER DELETE ON "identity" REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE process_audit(); +CREATE TRIGGER identity_updated_at BEFORE UPDATE ON "identity" FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); === diff --git a/packages/testing-runtime/src/Executor.mjs b/packages/testing-runtime/src/Executor.mjs index 4569875b0..7eed62125 100644 --- a/packages/testing-runtime/src/Executor.mjs +++ b/packages/testing-runtime/src/Executor.mjs @@ -102,8 +102,19 @@ export class Executor { } if (this._parseJsonResult) { - return r.json(); + return r.text().then((t) => { + return JSON.parse(t, reviver); + }); } }); } } + +const dateFormat = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:(\d{2}(?:\.\d*))Z$/; + +function reviver(key, value) { + if (typeof value === "string" && dateFormat.test(value)) { + return new Date(value); + } + return value; +} diff --git a/runtime/runtime_events_test.go b/runtime/runtime_events_test.go index 0e049e6a2..71fb6cbc1 100644 --- a/runtime/runtime_events_test.go +++ b/runtime/runtime_events_test.go @@ -177,7 +177,8 @@ func TestUpdateEvent(t *testing.T) { updatedAt, err := time.Parse("2006-01-02T15:04:05.999999999-07:00", data.String("updatedAt")) require.NoError(t, err) - require.Equal(t, wedding["updatedAt"], updatedAt) + require.NotEqual(t, wedding["updatedAt"], updatedAt) + require.Equal(t, updatedWedding["updatedAt"], updatedAt) } func TestDeleteEvent(t *testing.T) {