From 68cdf32290e0ca67b2f8f1f6293663a35b008d68 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Wed, 25 Mar 2026 16:42:26 +0300 Subject: [PATCH] orm: add rails style updates for struct (fixes #22531) --- vlib/orm/README.md | 26 ++++++++++++++ vlib/orm/orm_func.v | 54 +++++++++++++++++++++++++++++ vlib/orm/orm_save_test.v | 74 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 vlib/orm/orm_save_test.v diff --git a/vlib/orm/README.md b/vlib/orm/README.md index 02fa8b6e6..f2131e199 100644 --- a/vlib/orm/README.md +++ b/vlib/orm/README.md @@ -278,6 +278,22 @@ sql db { }! ``` +For a Rails-style full-record save, load a struct, mutate it, then call `orm.save`. +The helper uses the struct primary key, or an `id` field when present, for the +`WHERE` clause and updates the remaining mapped fields automatically. + +```v ignore +import orm + +mut foo := (sql db { + select from Foo where id == 1 +}!).first() +foo.name = 'updated' +foo.updated_at = time.now() + +orm.save(db, foo)! +``` + Note that `is none` and `!is none` can be used to select for NULL fields. ### Delete @@ -417,6 +433,16 @@ struct User { qb.set('age = ?, title = ?', 71, 'boss')!.where('name = ?','John')!.update()! ``` +For a full-record update without spelling out each `set(...)` clause, use `orm.save`: + +```v ignore + selected := qb.where('name = ?', 'John')!.query()! + mut john := selected.first() + john.age = 72 + john.title = 'lead' + orm.save(db, john)! +``` + 9. Query aggregate values​​: ```v ignore diff --git a/vlib/orm/orm_func.v b/vlib/orm/orm_func.v index 8fef4572c..2768b6b88 100644 --- a/vlib/orm/orm_func.v +++ b/vlib/orm/orm_func.v @@ -1021,6 +1021,60 @@ pub fn (qb_ &QueryBuilder[T]) insert_many[T](values []T) !&QueryBuilder[T] { return qb } +// save updates all mapped fields in `value` using the struct primary key or `id` field. +pub fn save[T](conn Connection, value T) ! { + mut qb := new_query[T](conn) + data, where := build_save_query_data[T](qb.meta, qb.config.table.name, value)! + qb.conn.update(qb.config.table, data, where)! +} + +fn build_save_query_data[T](meta []TableField, table_name string, value T) !(QueryData, QueryData) { + data := fill_data_with_struct[T](value, meta) + if data.fields.len != data.data.len { + return error('${@FN}(): table `${table_name}` contains fields that `save` cannot map automatically') + } + primary_field_name := find_save_primary_field_name(meta) or { + return error('${@FN}(): table `${table_name}` needs a primary key or `id` field to use `save`') + } + mut update_data := QueryData{} + mut where_data := QueryData{ + kinds: [.eq] + } + for i, field_name in data.fields { + if field_name == primary_field_name { + where_data.fields << field_name + where_data.data << data.data[i] + continue + } + update_data.fields << field_name + update_data.data << data.data[i] + } + if where_data.fields.len == 0 { + return error('${@FN}(): struct value is missing the primary key field `${primary_field_name}`') + } + if update_data.fields.len == 0 { + return error('${@FN}(): no updatable fields were found for table `${table_name}`') + } + return update_data, where_data +} + +fn find_save_primary_field_name(meta []TableField) ?string { + for field in meta { + for attr in field.attrs { + if attr_name_matches(attr.name, 'primary') { + return sql_field_name(field) + } + } + } + for field in meta { + field_name := sql_field_name(field) + if field.name == 'id' || field_name == 'id' { + return field_name + } + } + return none +} + fn fill_data_with_struct[T](value T, meta []TableField) QueryData { mut qb := QueryData{} $for field in T.fields { diff --git a/vlib/orm/orm_save_test.v b/vlib/orm/orm_save_test.v new file mode 100644 index 000000000..07fe49e57 --- /dev/null +++ b/vlib/orm/orm_save_test.v @@ -0,0 +1,74 @@ +// vtest retry: 3 +// vtest build: present_sqlite3? +import db.sqlite +import orm +import time + +@[table: 'save_users'] +struct SaveUser { +mut: + id int @[primary; sql: serial] + name string + age int + nickname ?string + updated_at time.Time +} + +fn test_save_updates_all_mapped_fields_using_primary_key() { + mut db := sqlite.connect(':memory:') or { panic(err) } + defer { + db.close() or {} + } + + sql db { + create table SaveUser + }! + + first := SaveUser{ + name: 'Alice' + age: 30 + nickname: 'ally' + updated_at: time.unix(100) + } + second := SaveUser{ + name: 'Bob' + age: 28 + nickname: 'b' + updated_at: time.unix(150) + } + + sql db { + insert first into SaveUser + insert second into SaveUser + }! + + mut selected := sql db { + select from SaveUser where name == 'Alice' + }! + assert selected.len == 1 + + mut updated := selected[0] + updated.name = 'Alice Updated' + updated.age = 31 + updated.nickname = none + updated.updated_at = time.unix(200) + + orm.save(db, updated)! + + selected = sql db { + select from SaveUser where id == updated.id + }! + assert selected.len == 1 + assert selected[0].id == updated.id + assert selected[0].name == 'Alice Updated' + assert selected[0].age == 31 + assert selected[0].nickname == none + assert selected[0].updated_at == time.unix(200) + + untouched := sql db { + select from SaveUser where name == 'Bob' + }! + assert untouched.len == 1 + assert untouched[0].age == 28 + assert untouched[0].nickname or { '' } == 'b' +} -- 2.39.5