diff --git a/integration/testdata/built_in_actions/main.test.ts b/integration/testdata/built_in_actions/main.test.ts index f50d4b389..06a235cdc 100644 --- a/integration/testdata/built_in_actions/main.test.ts +++ b/integration/testdata/built_in_actions/main.test.ts @@ -247,6 +247,26 @@ 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 +280,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) ); +} \ No newline at end of file diff --git a/integration/testdata/with_custom_function_hooks/main.test.ts b/integration/testdata/with_custom_function_hooks/main.test.ts index 0188296ab..33782ba6f 100644 --- a/integration/testdata/with_custom_function_hooks/main.test.ts +++ b/integration/testdata/with_custom_function_hooks/main.test.ts @@ -86,6 +86,47 @@ describe("write hooks", () => { }) ).toHaveAuthorizationError(); }); + + + test("update action - 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, + }, + }); + console.log(person); + console.log(record); + + const person2 = await models.person.findOne({id: person.id}); + console.log(person2); + + + 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..782c28cbb 100644 --- a/migrations/testdata/default_value_initial.txt +++ b/migrations/testdata/default_value_initial.txt @@ -21,7 +21,7 @@ model Person { === CREATE TABLE "identity" ( -"email" TEXT, +"email" TEXT,pauthen "email_verified" BOOL NOT NULL DEFAULT false, "password" TEXT, "external_id" TEXT, @@ -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..28236d2f5 100644 --- a/migrations/testdata/model_added.txt +++ b/migrations/testdata/model_added.txt @@ -34,6 +34,7 @@ ALTER TABLE "animal" ADD FOREIGN KEY ("human_friend_id") REFERENCES "person"("id 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..2f2895255 100644 --- a/packages/testing-runtime/src/Executor.mjs +++ b/packages/testing-runtime/src/Executor.mjs @@ -75,7 +75,7 @@ export class Executor { method: "POST", body: JSON.stringify(params), headers, - }).then((r) => { + }).then(async (r) => { if (r.status !== 200) { // For non-200 first read the response as text return r.text().then((t) => { @@ -101,9 +101,20 @@ export class Executor { }); } - if (this._parseJsonResult) { - return r.json(); - } + return r.text().then((t) => { + if (this._parseJsonResult) { + 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; +} \ No newline at end of file diff --git a/runtime/actions/query.go b/runtime/actions/query.go index 1ad701e7a..a36b3ecc5 100644 --- a/runtime/actions/query.go +++ b/runtime/actions/query.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "strings" + "time" "github.com/samber/lo" "github.com/teamkeel/keel/casing" @@ -811,6 +812,9 @@ func orderGraphNodes(graph *Row) []*Row { // Generates an executable UPDATE statement with the list of arguments. func (query *QueryBuilder) UpdateStatement() *Statement { + // Implicitly update the updatedAt column for any update to the model + query.AddWriteValue(Field(parser.ImplicitFieldNameUpdatedAt), Value(time.Now().UTC())) + queryFilters := query.filters joins := ""