From d5578efae67ef01ab3a63866d429b7dacf6c237d Mon Sep 17 00:00:00 2001 From: Jengro Date: Wed, 13 May 2026 15:37:32 +0800 Subject: [PATCH] orm: add bulk insert/update support with CASE WHEN batch updates, MySQL time conversion, and checker/codegen validation (#27132) --- vlib/db/mysql/orm.c.v | 28 +- vlib/orm/README.md | 41 +++ vlib/orm/orm.v | 174 +++++++++--- vlib/orm/orm_fn_test.v | 96 +++++++ vlib/orm/orm_func.v | 81 +++++- vlib/orm/orm_func_test.v | 97 +++++++ vlib/v/ast/ast.v | 4 + vlib/v/checker/orm.v | 137 +++++++++- .../tests/orm_bulk_pointer_array_error.out | 14 + .../tests/orm_bulk_pointer_array_error.vv | 29 ++ vlib/v/gen/c/orm.v | 251 +++++++++++++++++- vlib/v/tests/orm_bulk_insert_update_test.v | 194 ++++++++++++++ 12 files changed, 1091 insertions(+), 55 deletions(-) create mode 100644 vlib/v/checker/tests/orm_bulk_pointer_array_error.out create mode 100644 vlib/v/checker/tests/orm_bulk_pointer_array_error.vv create mode 100644 vlib/v/tests/orm_bulk_insert_update_test.v diff --git a/vlib/db/mysql/orm.c.v b/vlib/db/mysql/orm.c.v index 5175a8522..ba894ecfb 100644 --- a/vlib/db/mysql/orm.c.v +++ b/vlib/db/mysql/orm.c.v @@ -141,11 +141,15 @@ pub fn (db DB) insert(table orm.Table, data orm.QueryData) ! { mut converted_primitive_array := db.convert_query_data_to_primitives(table.name, data)! converted_primitive_data := orm.QueryData{ - fields: data.fields - data: converted_primitive_array - types: [] - kinds: [] - is_and: [] + fields: data.fields + data: converted_primitive_array + types: data.types + parentheses: data.parentheses + kinds: data.kinds + auto_fields: data.auto_fields + is_and: data.is_and + batch_rows: data.batch_rows + batch_key: data.batch_key } query, converted_data := orm.orm_stmt_gen(.default, table, '`', .insert, false, '?', 1, @@ -509,16 +513,20 @@ fn mysql_type_from_v(typ int) !string { fn (db DB) convert_query_data_to_primitives(table string, data orm.QueryData) ![]orm.Primitive { mut column_type_map := db.get_table_column_type_map(table)! mut converted_data := []orm.Primitive{} + if data.fields.len == 0 { + return converted_data + } - for i, field in data.fields { - if data.data[i].type_name() == 'time.Time' { + for i, primitive in data.data { + field := data.fields[i % data.fields.len] + if primitive.type_name() == 'time.Time' { if column_type_map[field] in ['datetime', 'timestamp'] { - converted_data << orm.Primitive((data.data[i] as time.Time).str()) + converted_data << orm.Primitive((primitive as time.Time).str()) } else { - converted_data << data.data[i] + converted_data << primitive } } else { - converted_data << data.data[i] + converted_data << primitive } } diff --git a/vlib/orm/README.md b/vlib/orm/README.md index 2e86482e5..05e02b77d 100644 --- a/vlib/orm/README.md +++ b/vlib/orm/README.md @@ -159,6 +159,27 @@ foo_id := sql db { }! ``` +You can insert a flat array of records in one statement. Bulk inserts currently +support primitive, enum, and `time.Time` fields. Rows with `serial` or `default` +fields fall back to per-row inserts so each row keeps normal default handling. + +```v ignore +users := [ + User{ + id: 1 + name: 'Alice' + }, + User{ + id: 2 + name: 'Bob' + }, +] + +sql db { + insert users into User +}! +``` + If the `id` field is marked as `sql: serial` and `primary`, the insert expression returns the database ID of the newly added object. Getting an ID of a newly added DB row is often useful. @@ -308,6 +329,26 @@ sql db { }! ``` +You can update multiple rows from an array in one statement by using the array +variable in each assigned value and in the key comparison. + +```v ignore +updates := [ + User{ + id: 1 + name: 'Alicia' + }, + User{ + id: 2 + name: 'Robert' + }, +] + +sql db { + update User set name = updates.name where id == updates.id +}! +``` + 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. diff --git a/vlib/orm/orm.v b/vlib/orm/orm.v index d9528790c..2a48c3b98 100644 --- a/vlib/orm/orm.v +++ b/vlib/orm/orm.v @@ -214,6 +214,8 @@ pub mut: kinds []OperationKind auto_fields []int is_and []bool + batch_rows int + batch_key string } pub struct InfixType { @@ -626,6 +628,8 @@ fn clone_query_data(data QueryData) QueryData { kinds: data.kinds.clone() auto_fields: data.auto_fields.clone() is_and: data.is_and.clone() + batch_rows: data.batch_rows + batch_key: data.batch_key } } @@ -642,64 +646,99 @@ pub fn orm_stmt_gen(sql_dialect SQLDialect, table Table, q string, kind StmtKind match kind { .insert { + row_count := if insert_data.batch_rows > 0 { insert_data.batch_rows } else { 1 } mut values := []string{} mut select_fields := []string{} + are_values_empty := insert_data.fields.len == 0 for column_name in insert_data.fields { select_fields << '${q}${column_name}${q}' - values << factory_insert_qm_value(num, qm, c) - c++ + } + if !are_values_empty { + for _ in 0 .. row_count { + mut row_values := []string{} + for _ in insert_data.fields { + row_values << factory_insert_qm_value(num, qm, c) + c++ + } + values << '(${row_values.join(', ')})' + } } str += 'INSERT INTO ${q}${table.name}${q} ' - are_values_empty := values.len == 0 - - if sql_dialect in [.sqlite, .pg, .h2] && are_values_empty { - str += 'DEFAULT VALUES' + if are_values_empty { + if row_count == 1 && sql_dialect in [.sqlite, .pg, .h2] { + str += 'DEFAULT VALUES' + } else { + str += '() VALUES ' + str += []string{len: row_count, init: '()'}.join(', ') + } } else { str += '(' str += select_fields.join(', ') - str += ') VALUES (' + str += ') VALUES ' str += values.join(', ') - str += ')' } } .update { str += 'UPDATE ${q}${table.name}${q} SET ' - for i, field in data.fields { - str += '${q}${field}${q} = ' - if data.data.len > i { - d := data.data[i] - if d is InfixType { - op := match d.operator { - .add { - '+' - } - .sub { - '-' - } - .mul { - '*' - } - .div { - '/' - } + if data.batch_rows > 0 { + for i, field in data.fields { + str += '${q}${field}${q} = CASE ${q}${data.batch_key}${q} ' + for _ in 0 .. data.batch_rows { + str += 'WHEN ${qm}' + if num { + str += '${c}' + c++ } + str += ' THEN ${qm}' + if num { + str += '${c}' + c++ + } + str += ' ' + } + str += 'ELSE ${q}${field}${q} END' + if i < data.fields.len - 1 { + str += ', ' + } + } + } else { + for i, field in data.fields { + str += '${q}${field}${q} = ' + if data.data.len > i { + d := data.data[i] + if d is InfixType { + op := match d.operator { + .add { + '+' + } + .sub { + '-' + } + .mul { + '*' + } + .div { + '/' + } + } - str += '${d.name} ${op} ${qm}' + str += '${d.name} ${op} ${qm}' + } else { + str += '${qm}' + } } else { str += '${qm}' } - } else { - str += '${qm}' - } - if num { - str += '${c}' - c++ - } - if i < data.fields.len - 1 { - str += ', ' + if num { + str += '${c}' + c++ + } + if i < data.fields.len - 1 { + str += ', ' + } } } str += ' WHERE ' @@ -727,14 +766,62 @@ pub fn orm_stmt_gen(sql_dialect SQLDialect, table Table, q string, kind StmtKind fn prepare_insert_query_data(data QueryData) QueryData { mut prepared := QueryData{ - types: data.types.clone() - kinds: data.kinds.clone() - auto_fields: data.auto_fields.clone() + batch_rows: data.batch_rows + batch_key: data.batch_key + parentheses: data.parentheses.clone() is_and: data.is_and.clone() } + mut included_indexes := []int{} + if data.batch_rows > 0 && data.fields.len > 0 { + for i, column_name in data.fields { + mut skip_auto_field := i in data.auto_fields + if skip_auto_field { + for row in 0 .. data.batch_rows { + data_idx := row * data.fields.len + i + if data_idx >= data.data.len + || !should_skip_insert_auto_field(data.data[data_idx]) { + skip_auto_field = false + break + } + } + } + if skip_auto_field { + continue + } + prepared.fields << column_name + if i < data.types.len { + prepared.types << data.types[i] + } + if i < data.kinds.len { + prepared.kinds << data.kinds[i] + } + if i in data.auto_fields { + prepared.auto_fields << prepared.fields.len - 1 + } + included_indexes << i + } + for row in 0 .. data.batch_rows { + for i in included_indexes { + data_idx := row * data.fields.len + i + if data_idx < data.data.len { + prepared.data << data.data[data_idx] + } + } + } + return prepared + } for i, column_name in data.fields { if i >= data.data.len { prepared.fields << column_name + if i < data.types.len { + prepared.types << data.types[i] + } + if i < data.kinds.len { + prepared.kinds << data.kinds[i] + } + if i in data.auto_fields { + prepared.auto_fields << prepared.fields.len - 1 + } continue } if i in data.auto_fields && should_skip_insert_auto_field(data.data[i]) { @@ -742,6 +829,15 @@ fn prepare_insert_query_data(data QueryData) QueryData { } prepared.fields << column_name prepared.data << data.data[i] + if i < data.types.len { + prepared.types << data.types[i] + } + if i < data.kinds.len { + prepared.kinds << data.kinds[i] + } + if i in data.auto_fields { + prepared.auto_fields << prepared.fields.len - 1 + } } return prepared } diff --git a/vlib/orm/orm_fn_test.v b/vlib/orm/orm_fn_test.v index e2b577d8b..44d36941b 100644 --- a/vlib/orm/orm_fn_test.v +++ b/vlib/orm/orm_fn_test.v @@ -47,6 +47,78 @@ fn test_orm_stmt_gen_insert() { assert query == "INSERT INTO 'Test' ('test', 'a') VALUES (?0, ?1);" } +fn test_orm_stmt_gen_bulk_insert() { + table := orm.Table{ + name: 'Test' + } + query, converted := orm.orm_stmt_gen(.default, table, "'", .insert, true, '?', 0, orm.QueryData{ + fields: ['name', 'age'] + data: [orm.Primitive('Alice'), orm.Primitive(25), orm.Primitive('Bob'), orm.Primitive(30)] + batch_rows: 2 + }, orm.QueryData{}) + assert query == "INSERT INTO 'Test' ('name', 'age') VALUES (?0, ?1), (?2, ?3);" + assert converted.data.len == 4 + + pg_query, _ := orm.orm_stmt_gen(.pg, table, '"', .insert, true, '$', 1, orm.QueryData{ + fields: ['name', 'age'] + data: [orm.Primitive('Alice'), orm.Primitive(25), orm.Primitive('Bob'), orm.Primitive(30)] + batch_rows: 2 + }, orm.QueryData{}) + assert pg_query == 'INSERT INTO "Test" ("name", "age") VALUES ($1, $2), ($3, $4);' + + mysql_query, _ := orm.orm_stmt_gen(.mysql, table, '`', .insert, false, '?', 1, orm.QueryData{ + fields: ['name', 'age'] + data: [orm.Primitive('Alice'), orm.Primitive(25), orm.Primitive('Bob'), orm.Primitive(30)] + batch_rows: 2 + }, orm.QueryData{}) + assert mysql_query == 'INSERT INTO `Test` (`name`, `age`) VALUES (?, ?), (?, ?);' +} + +fn test_orm_stmt_gen_bulk_update() { + table := orm.Table{ + name: 'Test' + } + query, _ := orm.orm_stmt_gen(.default, table, "'", .update, true, '?', 0, orm.QueryData{ + fields: ['name', 'age'] + data: [orm.Primitive(1), orm.Primitive('Alice'), orm.Primitive(2), orm.Primitive('Bob'), + orm.Primitive(1), orm.Primitive(25), orm.Primitive(2), orm.Primitive(30)] + batch_rows: 2 + batch_key: 'id' + }, orm.QueryData{ + fields: ['id', 'id'] + data: [orm.Primitive(1), orm.Primitive(2)] + kinds: [.eq, .eq] + is_and: [false] + }) + assert query == "UPDATE 'Test' SET 'name' = CASE 'id' WHEN ?0 THEN ?1 WHEN ?2 THEN ?3 ELSE 'name' END, 'age' = CASE 'id' WHEN ?4 THEN ?5 WHEN ?6 THEN ?7 ELSE 'age' END WHERE 'id' = ?8 OR 'id' = ?9;" + + pg_query, _ := orm.orm_stmt_gen(.pg, table, '"', .update, true, '$', 1, orm.QueryData{ + fields: ['name'] + data: [orm.Primitive(1), orm.Primitive('Alice'), orm.Primitive(2), orm.Primitive('Bob')] + batch_rows: 2 + batch_key: 'id' + }, orm.QueryData{ + fields: ['id', 'id'] + data: [orm.Primitive(1), orm.Primitive(2)] + kinds: [.eq, .eq] + is_and: [false] + }) + assert pg_query == 'UPDATE "Test" SET "name" = CASE "id" WHEN $1 THEN $2 WHEN $3 THEN $4 ELSE "name" END WHERE "id" = $5 OR "id" = $6;' + + mysql_query, _ := orm.orm_stmt_gen(.mysql, table, '`', .update, false, '?', 1, orm.QueryData{ + fields: ['name'] + data: [orm.Primitive(1), orm.Primitive('Alice'), orm.Primitive(2), orm.Primitive('Bob')] + batch_rows: 2 + batch_key: 'id' + }, orm.QueryData{ + fields: ['id', 'id'] + data: [orm.Primitive(1), orm.Primitive(2)] + kinds: [.eq, .eq] + is_and: [false] + }) + assert mysql_query == 'UPDATE `Test` SET `name` = CASE `id` WHEN ? THEN ? WHEN ? THEN ? ELSE `name` END WHERE `id` = ? OR `id` = ?;' +} + fn test_orm_stmt_gen_insert_default_values_pg() { table := orm.Table{ name: 'Test' @@ -63,6 +135,30 @@ fn test_orm_stmt_gen_insert_default_values_pg() { assert converted.data.len == 0 } +fn test_orm_stmt_gen_insert_default_values_mysql() { + table := orm.Table{ + name: 'Test' + } + query, converted := orm.orm_stmt_gen(.mysql, table, '`', .insert, false, '?', 1, orm.QueryData{ + fields: ['id'] + data: [orm.Primitive(0)] + auto_fields: [0] + }, orm.QueryData{}) + assert query == 'INSERT INTO `Test` () VALUES ();' + assert converted.fields.len == 0 + assert converted.data.len == 0 + + bulk_query, bulk_converted := orm.orm_stmt_gen(.mysql, table, '`', .insert, false, '?', 1, orm.QueryData{ + fields: ['id'] + data: [orm.Primitive(0), orm.Primitive(0), orm.Primitive(0)] + auto_fields: [0] + batch_rows: 3 + }, orm.QueryData{}) + assert bulk_query == 'INSERT INTO `Test` () VALUES (), (), ();' + assert bulk_converted.fields.len == 0 + assert bulk_converted.data.len == 0 +} + fn test_orm_stmt_gen_h2_insert_default_values() { table := orm.Table{ name: 'Test' diff --git a/vlib/orm/orm_func.v b/vlib/orm/orm_func.v index 7621151ec..758d93097 100644 --- a/vlib/orm/orm_func.v +++ b/vlib/orm/orm_func.v @@ -1016,6 +1016,7 @@ pub fn (qb_ &QueryBuilder[T]) insert[T](value T) !&QueryBuilder[T] { } // insert_many insert records into the database +// Uses batch INSERT for efficiency when inserting multiple records. pub fn (qb_ &QueryBuilder[T]) insert_many[T](values []T) !&QueryBuilder[T] { mut qb := unsafe { qb_ } defer { @@ -1025,10 +1026,15 @@ pub fn (qb_ &QueryBuilder[T]) insert_many[T](values []T) !&QueryBuilder[T] { if values.len == 0 { return error('${@FN}(): `insert` need at least one record') } - for value in values { - new_qb := fill_data_with_struct[T](value, qb.meta) - qb.conn.insert(qb.config.table, new_qb)! + mut batch := fill_data_with_struct[T](values[0], qb.meta) + batch.batch_rows = values.len + for i in 1 .. values.len { + next := fill_data_with_struct[T](values[i], qb.meta) + for d in next.data { + batch.data << d + } } + qb.conn.insert(qb.config.table, batch)! return qb } @@ -1199,6 +1205,75 @@ pub fn (qb_ &QueryBuilder[T]) update() !&QueryBuilder[T] { return qb } +// update_many updates multiple records by a key field, using batch CASE WHEN for efficiency. +// key_field is the column used to match rows (e.g. 'id'). +// field_names selects which columns to update; if empty, all struct fields except key_field are updated. +pub fn update_many[T](mut conn Connection, values []T, key_field string, field_names ...string) ! { + if values.len == 0 { + return error('${@FN}(): need at least one record') + } + mut qb := new_query[T](conn) + + // Build the field list from the first value + first := fill_data_with_struct[T](values[0], qb.meta) + mut key_index := -1 + mut value_fields := []string{} + mut value_indexes := []int{} + + for i, field in first.fields { + if field == key_field { + key_index = i + } else if field_names.len == 0 || field in field_names { + value_fields << field + value_indexes << i + } + } + + if key_index < 0 { + return error('${@FN}(): key field `${key_field}` not found in table `${qb.config.table.name}`') + } + + if value_fields.len == 0 { + return error('${@FN}(): no updatable fields found for table `${qb.config.table.name}`') + } + + mut update_data := QueryData{ + fields: value_fields + batch_rows: values.len + batch_key: key_field + } + + // Build data: per value_field, per row: [key_value, value_field_value] + rows := values.map(fill_data_with_struct[T](it, qb.meta)) + + for fj in value_indexes { + for row in rows { + if key_index < row.data.len { + update_data.data << row.data[key_index] + } + if fj < row.data.len { + update_data.data << row.data[fj] + } + } + } + + // Build WHERE clause using IN + mut key_values := []Primitive{} + for row in rows { + if key_index < row.data.len { + key_values << row.data[key_index] + } + } + + mut where_data := QueryData{ + fields: [key_field] + data: [Primitive(key_values)] + kinds: [.in] + } + + conn.update(qb.config.table, update_data, where_data)! +} + // delete delete record(s) in the database pub fn (qb_ &QueryBuilder[T]) delete() !&QueryBuilder[T] { mut qb := unsafe { qb_ } diff --git a/vlib/orm/orm_func_test.v b/vlib/orm/orm_func_test.v index d16cbf8a3..b29a6223e 100644 --- a/vlib/orm/orm_func_test.v +++ b/vlib/orm/orm_func_test.v @@ -660,3 +660,100 @@ fn test_orm_func_invalid_index_field_name2() { } assert false, 'should not be here' } + +fn test_orm_func_update_many() { + mut db := sqlite.connect(':memory:')! + defer { db.close() or {} } + mut qb := orm.new_query[User](db) + + qb.create()! + + // Insert test records + users := [ + User{ + name: 'Alice' + age: 25 + role: 'developer' + }, + User{ + name: 'Bob' + age: 30 + role: 'manager' + }, + User{ + name: 'Carol' + age: 35 + role: 'designer' + }, + ] + qb.insert_many(users)! + + // Verify initial data + all_users := qb.query()! + assert all_users.len == 3 + assert all_users[0].name == 'Alice' + assert all_users[1].name == 'Bob' + assert all_users[2].name == 'Carol' + + // Batch update names by id + orm.update_many[User](mut db, [ + User{ + id: 1 + name: 'Alice_updated' + age: 26 + }, + User{ + id: 2 + name: 'Bob_updated' + age: 31 + }, + ], 'id', 'name', 'age')! + + // Verify updated data + updated_users := qb.query()! + assert updated_users.len == 3 + for u in updated_users { + match u.id { + 1 { + assert u.name == 'Alice_updated' + assert u.age == 26 + assert u.role == 'developer' + } + 2 { + assert u.name == 'Bob_updated' + assert u.age == 31 + assert u.role == 'manager' + } + 3 { + assert u.name == 'Carol' + assert u.age == 35 + assert u.role == 'designer' + } + else { + assert false + } + } + } + + // Test update_many with single record + orm.update_many[User](mut db, [ + User{ + id: 3 + name: 'Carol_updated' + age: 36 + }, + ], 'id', 'name', 'age')! + + single_result := qb.where('id = ?', 3)!.query()! + assert single_result.len == 1 + assert single_result[0].name == 'Carol_updated' + assert single_result[0].age == 36 + assert single_result[0].role == 'designer' + + // Test update_many with empty values + orm.update_many[User](mut db, []User{}, 'id', 'name') or { + assert err.msg().contains('need at least one record') + return + } + assert false, 'should not be here' +} diff --git a/vlib/v/ast/ast.v b/vlib/v/ast/ast.v index 013b1604d..4b34c4969 100644 --- a/vlib/v/ast/ast.v +++ b/vlib/v/ast/ast.v @@ -2372,6 +2372,10 @@ pub: pub mut: object_var string // `user` updated_columns []string // for `update set x=y` + is_array_insert bool + is_array_update bool + array_update_var string + array_update_key string table_expr TypeNode fields []StructField sub_structs map[string]SqlStmtLine diff --git a/vlib/v/checker/orm.v b/vlib/v/checker/orm.v index a253ec7d0..bb4fbbf7d 100644 --- a/vlib/v/checker/orm.v +++ b/vlib/v/checker/orm.v @@ -556,6 +556,7 @@ fn (mut c Checker) sql_stmt_line(mut node ast.SqlStmtLine) ast.Type { return ast.void_type } mut inserting_object_type := inserting_object.typ + mut is_array_insert := false if inserting_object_type.is_ptr() { inserting_object_type = inserting_object.typ.deref() @@ -566,6 +567,27 @@ fn (mut c Checker) sql_stmt_line(mut node ast.SqlStmtLine) ast.Type { inserting_object_type = resolved_object_type } + insert_sym := c.table.sym(inserting_object_type) + if insert_sym.kind == .array { + if node.kind == .upsert { + c.orm_error('upsert currently does not support arrays', node.pos) + return ast.void_type + } + is_array_insert = true + elem_type := insert_sym.array_info().elem_type + if elem_type.is_ptr() { + c.orm_error('bulk ${node.kind} currently supports only arrays of `${table_sym.name}` values', + node.pos) + return ast.void_type + } + inserting_object_type = c.unwrap_generic(elem_type) + if inserting_object_type != node.table_expr.typ { + c.orm_error('bulk ${node.kind} currently supports only arrays of `${table_sym.name}` values', + node.pos) + return ast.void_type + } + } + if inserting_object_type != node.table_expr.typ && !c.table.sumtype_has_variant(inserting_object_type, node.table_expr.typ, false) { table_name := table_sym.name @@ -574,6 +596,7 @@ fn (mut c Checker) sql_stmt_line(mut node ast.SqlStmtLine) ast.Type { c.error('cannot use `${inserting_type_name}` as `${table_name}`', node.pos) return ast.void_type } + node.is_array_insert = is_array_insert } if table_sym.info !is ast.Struct { @@ -600,6 +623,14 @@ fn (mut c Checker) sql_stmt_line(mut node ast.SqlStmtLine) ast.Type { mut sub_structs := map[string]ast.SqlStmtLine{} non_primitive_fields := c.get_orm_non_primitive_fields(fields) + if node.is_array_insert && non_primitive_fields.len > 0 { + for field in non_primitive_fields { + c.orm_error('bulk ${node.kind} currently supports only primitive, enum, and time.Time fields', + field.pos) + } + return ast.void_type + } + for field in non_primitive_fields { field_typ, field_sym := c.get_non_array_type(field.typ) if field_sym.kind == .struct && (field_typ.idx() == node.table_expr.typ.idx() @@ -675,6 +706,7 @@ fn (mut c Checker) sql_stmt_line(mut node ast.SqlStmtLine) ast.Type { if !c.check_dynamic_sql_query_data(node.update_data_expr, table_sym, node.fields, .set_) { return ast.void_type } + } else if c.check_orm_array_update(mut node, table_sym) { } else { for i, mut expr in node.update_exprs { column := node.updated_columns[i] @@ -688,13 +720,116 @@ fn (mut c Checker) sql_stmt_line(mut node ast.SqlStmtLine) ast.Type { } } - if node.where_expr !is ast.EmptyExpr { + if node.where_expr !is ast.EmptyExpr && !node.is_array_update { c.expr(mut node.where_expr) } return ast.void_type } +fn (mut c Checker) check_orm_array_update(mut node ast.SqlStmtLine, table_sym &ast.TypeSymbol) bool { + if node.update_exprs.len == 0 || node.where_expr !is ast.InfixExpr { + return false + } + where_expr := node.where_expr as ast.InfixExpr + if where_expr.op != .eq || where_expr.left !is ast.Ident + || where_expr.right !is ast.SelectorExpr { + return false + } + key_ident := where_expr.left as ast.Ident + key_selector := where_expr.right as ast.SelectorExpr + if key_selector.expr !is ast.Ident { + return false + } + array_ident := key_selector.expr as ast.Ident + array_name := array_ident.name + array_elem_type := c.orm_array_object_elem_type(node, array_name) or { return false } + if array_elem_type.is_ptr() { + node.is_array_update = true + c.orm_error('bulk update currently supports only arrays of `${table_sym.name}` values', + array_ident.pos) + return true + } + if c.unwrap_generic(array_elem_type) != node.table_expr.typ { + return false + } + info := table_sym.info as ast.Struct + key_field := c.orm_struct_field(info.fields, key_ident.name) or { + c.orm_error('type `${table_sym.name}` has no field named `${key_ident.name}`', + key_ident.pos) + return true + } + selector_key_field := c.orm_struct_field(info.fields, key_selector.field_name) or { + c.orm_error('type `${table_sym.name}` has no field named `${key_selector.field_name}`', + key_selector.pos) + return true + } + if !c.check_types(selector_key_field.typ, key_field.typ) { + c.orm_error('cannot use `${key_selector.field_name}` as update key `${key_ident.name}`', + key_selector.pos) + return true + } + for i, expr in node.update_exprs { + if expr !is ast.SelectorExpr { + return false + } + selector := expr as ast.SelectorExpr + if selector.expr !is ast.Ident { + return false + } + update_array_ident := selector.expr as ast.Ident + if update_array_ident.name != array_name { + return false + } + value_field := c.orm_struct_field(info.fields, selector.field_name) or { + c.orm_error('type `${table_sym.name}` has no field named `${selector.field_name}`', + selector.pos) + return true + } + column := node.updated_columns[i] + target_field := c.get_orm_field_by_column_name(node.fields, column) or { return false } + target_sym := c.table.final_sym(target_field.typ.clear_flag(.option)) + if target_sym.kind == .struct && target_sym.name != 'time.Time' { + c.orm_error('bulk update currently supports only primitive, enum, and time.Time fields', + selector.pos) + return true + } + if !c.check_types(value_field.typ, target_field.typ) { + c.orm_error('cannot use `${selector.field_name}` as update value for `${target_field.name}`', + selector.pos) + return true + } + } + node.is_array_update = true + node.array_update_var = array_name + node.array_update_key = c.fetch_field_name(key_field) + return true +} + +fn (mut c Checker) orm_array_object_elem_type(node ast.SqlStmtLine, name string) ?ast.Type { + obj := node.scope.find(name) or { return none } + mut typ := obj.typ + if typ.is_ptr() { + typ = typ.deref() + } + typ = c.unwrap_generic(typ) + sym := c.table.sym(typ) + if sym.kind != .array { + return none + } + mut elem_type := sym.array_info().elem_type + return elem_type +} + +fn (_ &Checker) orm_struct_field(fields []ast.StructField, name string) ?ast.StructField { + for field in fields { + if field.name == name { + return field + } + } + return none +} + fn (mut c Checker) check_orm_struct_field_attrs(node ast.SqlStmtLine, field ast.StructField) { for attr in field.attrs { if attr.name == 'nonull' { diff --git a/vlib/v/checker/tests/orm_bulk_pointer_array_error.out b/vlib/v/checker/tests/orm_bulk_pointer_array_error.out new file mode 100644 index 000000000..7185de864 --- /dev/null +++ b/vlib/v/checker/tests/orm_bulk_pointer_array_error.out @@ -0,0 +1,14 @@ +vlib/v/checker/tests/orm_bulk_pointer_array_error.vv:26:10: error: ORM: bulk insert currently supports only arrays of `BulkPointerUser` values + 24 | update_users := [&update_user] + 25 | sql db { + 26 | insert insert_users into BulkPointerUser + | ~~~~~~~~~~~~ + 27 | update BulkPointerUpdateUser set name = update_users.name where id == update_users.id + 28 | }! +vlib/v/checker/tests/orm_bulk_pointer_array_error.vv:27:73: error: ORM: bulk update currently supports only arrays of `BulkPointerUpdateUser` values + 25 | sql db { + 26 | insert insert_users into BulkPointerUser + 27 | update BulkPointerUpdateUser set name = update_users.name where id == update_users.id + | ~~~~~~~~~~~~ + 28 | }! + 29 | } diff --git a/vlib/v/checker/tests/orm_bulk_pointer_array_error.vv b/vlib/v/checker/tests/orm_bulk_pointer_array_error.vv new file mode 100644 index 000000000..ffc6057dc --- /dev/null +++ b/vlib/v/checker/tests/orm_bulk_pointer_array_error.vv @@ -0,0 +1,29 @@ +import db.sqlite + +struct BulkPointerUser { + id int @[primary] + name string +} + +struct BulkPointerUpdateUser { + id int @[primary] + name string +} + +fn main() { + mut db := sqlite.connect(':memory:') or { panic(err) } + insert_user := BulkPointerUser{ + id: 1 + name: 'Alice' + } + insert_users := [&insert_user] + update_user := BulkPointerUpdateUser{ + id: 1 + name: 'Alice' + } + update_users := [&update_user] + sql db { + insert insert_users into BulkPointerUser + update BulkPointerUpdateUser set name = update_users.name where id == update_users.id + }! +} diff --git a/vlib/v/gen/c/orm.v b/vlib/v/gen/c/orm.v index 45c8ce4b5..5886f768d 100644 --- a/vlib/v/gen/c/orm.v +++ b/vlib/v/gen/c/orm.v @@ -670,7 +670,8 @@ fn (mut g Gen) sql_stmt_line(stmt_line ast.SqlStmtLine, connection_var_name stri g.write_orm_insert(node, table_name, connection_var_name, result_var_name, or_expr, table_attrs) } else if node.kind == .upsert { - g.write_orm_upsert(node, table_name, connection_var_name, result_var_name, table_attrs) + g.write_orm_upsert(node, table_name, connection_var_name, result_var_name, or_expr, + table_attrs) } else if node.kind == .update { g.write_orm_update(node, table_name, connection_var_name, result_var_name, table_attrs) } else if node.kind == .delete { @@ -842,6 +843,10 @@ fn (mut g Gen) write_orm_drop_table(node ast.SqlStmtLine, table_name string, con // write_orm_insert writes C code that calls ORM functions for inserting structs into a table. fn (mut g Gen) write_orm_insert(node &ast.SqlStmtLine, table_name string, connection_var_name string, result_var_name string, or_expr &ast.OrExpr, _ []ast.Attr) { + if node.is_array_insert { + g.write_orm_bulk_insert(node, table_name, connection_var_name, result_var_name, or_expr) + return + } last_ids_variable_name := g.new_tmp_var() g.writeln('Array_orm__Primitive ${last_ids_variable_name} = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__Primitive), 0);') @@ -849,8 +854,102 @@ fn (mut g Gen) write_orm_insert(node &ast.SqlStmtLine, table_name string, connec result_var_name, '', '', or_expr) } +fn (mut g Gen) write_orm_bulk_insert(node &ast.SqlStmtLine, table_name string, connection_var_name string, result_var_name string, or_expr &ast.OrExpr) { + fields := g.orm_non_array_fields(node.fields) + auto_fields := get_auto_field_idxs(fields) + row_type := g.styp(node.table_expr.typ) + row_var := g.new_tmp_var() + idx_var := g.new_tmp_var() + data_var := g.new_tmp_var() + g.writeln('${result_name}_void ${result_var_name};') + g.writeln('if (${node.object_var}.len == 0) {') + g.indent++ + g.writeln('${result_var_name} = (${result_name}_void){0};') + g.indent-- + g.writeln('} else {') + g.indent++ + if auto_fields.len > 0 { + g.writeln('${result_var_name} = (${result_name}_void){0};') + g.writeln('for (${ast.int_type_name} ${idx_var} = 0; ${idx_var} < ${node.object_var}.len; ${idx_var}++) {') + g.indent++ + g.writeln('${row_type} ${row_var} = (*(${row_type}*)builtin__array_get(${node.object_var}, ${idx_var}));') + row_result_var := g.new_tmp_var() + mut row_node := *node + row_node.object_var = row_var + row_node.is_array_insert = false + g.write_orm_insert(&row_node, table_name, connection_var_name, row_result_var, or_expr, + []ast.Attr{}) + g.or_block(row_result_var, *or_expr, ast.int_type.set_flag(.result)) + g.writeln('${result_var_name} = ${row_result_var};') + g.indent-- + g.writeln('}') + g.indent-- + g.writeln('}') + return + } + g.writeln('Array_orm__Primitive ${data_var} = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__Primitive), 0);') + g.writeln('for (${ast.int_type_name} ${idx_var} = 0; ${idx_var} < ${node.object_var}.len; ${idx_var}++) {') + g.indent++ + g.writeln('${row_type} ${row_var} = (*(${row_type}*)builtin__array_get(${node.object_var}, ${idx_var}));') + for field in fields { + g.write('builtin__array_push(&${data_var}, _MOV((orm__Primitive[1]){') + g.write_orm_field_access_to_primitive(field, row_var, node.table_expr.typ, + g.table.sym(node.table_expr.typ)) + g.writeln('}));') + } + g.indent-- + g.writeln('}') + g.writeln('// sql { insert into `${table_name}` }') + g.writeln('${result_var_name} = orm__Connection_name_table[${connection_var_name}._typ]._method_insert(') + g.indent++ + g.writeln('${connection_var_name}._object, // Connection object') + g.write_orm_table_struct(node.table_expr.typ) + g.writeln(',') + g.writeln('(orm__QueryData){') + g.indent++ + g.writeln('.fields = builtin__new_array_from_c_array(${fields.len}, ${fields.len}, sizeof(string),') + g.indent++ + if fields.len > 0 { + g.writeln('_MOV((string[${fields.len}]){') + g.indent++ + for f in fields { + g.writeln('_S("${g.get_orm_column_name_from_struct_field(f)}"),') + } + g.indent-- + g.writeln('})') + } else { + g.writeln('NULL') + } + g.indent-- + g.writeln('),') + g.writeln('.data = ${data_var},') + g.writeln('.types = builtin____new_array_with_default_noscan(0, 0, sizeof(${ast.int_type_name}), 0),') + if auto_fields.len > 0 { + g.writeln('.auto_fields = builtin__new_array_from_c_array(${auto_fields.len}, ${auto_fields.len}, sizeof(${ast.int_type_name}),') + g.indent++ + g.write('_MOV((${ast.int_type_name}[${auto_fields.len}]){') + for i in auto_fields { + g.write(' ${i},') + } + g.writeln(' })),') + g.indent-- + } else { + g.writeln('.auto_fields = builtin____new_array_with_default_noscan(0, 0, sizeof(${ast.int_type_name}), 0),') + } + g.writeln('.kinds = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__OperationKind), 0),') + g.writeln('.is_and = builtin____new_array_with_default_noscan(0, 0, sizeof(bool), 0),') + g.writeln('.parentheses = builtin____new_array_with_default_noscan(0, 0, sizeof(Array_${ast.int_type_name}), 0),') + g.writeln('.batch_rows = ${node.object_var}.len,') + g.indent-- + g.writeln('}') + g.indent-- + g.writeln(');') + g.indent-- + g.writeln('}') +} + fn (mut g Gen) write_orm_upsert(node &ast.SqlStmtLine, table_name string, connection_var_name string, result_var_name string, - table_attrs []ast.Attr) { + _ ast.OrExpr, table_attrs []ast.Attr) { g.writeln('// sql { upsert into `${table_name}` }') fields := node.fields auto_fields := get_auto_field_idxs(fields) @@ -1042,6 +1141,10 @@ fn (mut g Gen) write_orm_update(node &ast.SqlStmtLine, table_name string, connec if node.is_dynamic { dynamic_update_data_var = g.emit_dynamic_sql_query_data(node.update_data_expr) } + if node.is_array_update { + g.write_orm_bulk_update(node, table_name, connection_var_name, result_var_name) + return + } g.writeln('// sql { update `${table_name}` }') g.writeln('${result_name}_void ${result_var_name} = orm__Connection_name_table[${connection_var_name}._typ]._method_update(') g.indent++ @@ -1106,6 +1209,150 @@ fn (mut g Gen) write_orm_update(node &ast.SqlStmtLine, table_name string, connec g.writeln(');') } +fn (mut g Gen) write_orm_bulk_update(node &ast.SqlStmtLine, table_name string, connection_var_name string, result_var_name string) { + row_type := g.styp(node.table_expr.typ) + row_var := g.new_tmp_var() + idx_var := g.new_tmp_var() + data_var := g.new_tmp_var() + where_data_var := g.new_tmp_var() + where_fields_var := g.new_tmp_var() + where_kinds_var := g.new_tmp_var() + where_is_and_var := g.new_tmp_var() + key_field := g.get_orm_field_by_column_name(node.fields, node.array_update_key) or { + verror('ORM: field "${node.array_update_key}" does not exist on "${g.sql_table_name}"') + } + g.writeln('${result_name}_void ${result_var_name};') + g.writeln('if (${node.array_update_var}.len == 0) {') + g.indent++ + g.writeln('${result_var_name} = (${result_name}_void){0};') + g.indent-- + g.writeln('} else {') + g.indent++ + g.writeln('Array_orm__Primitive ${data_var} = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__Primitive), 0);') + g.writeln('Array_orm__Primitive ${where_data_var} = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__Primitive), 0);') + g.writeln('Array_string ${where_fields_var} = builtin____new_array_with_default_noscan(0, 0, sizeof(string), 0);') + g.writeln('Array_orm__OperationKind ${where_kinds_var} = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__OperationKind), 0);') + g.writeln('Array_bool ${where_is_and_var} = builtin____new_array_with_default_noscan(0, 0, sizeof(bool), 0);') + g.writeln('for (${ast.int_type_name} ${idx_var} = 0; ${idx_var} < ${node.array_update_var}.len; ${idx_var}++) {') + g.indent++ + g.writeln('${row_type} ${row_var} = (*(${row_type}*)builtin__array_get(${node.array_update_var}, ${idx_var}));') + g.writeln('builtin__array_push(&${where_fields_var}, _MOV((string[1]){ _S("${node.array_update_key}") }));') + g.writeln('builtin__array_push(&${where_kinds_var}, _MOV((orm__OperationKind[1]){ orm__OperationKind__eq }));') + g.writeln('if (${idx_var} > 0) {') + g.indent++ + g.writeln('builtin__array_push(&${where_is_and_var}, _MOV((bool[1]){ false }));') + g.indent-- + g.writeln('}') + g.write('builtin__array_push(&${where_data_var}, _MOV((orm__Primitive[1]){') + g.write_orm_field_access_to_primitive(key_field, row_var, node.table_expr.typ, + g.table.sym(node.table_expr.typ)) + g.writeln('}));') + g.indent-- + g.writeln('}') + for expr in node.update_exprs { + selector := expr as ast.SelectorExpr + value_field := get_orm_field_by_struct_field_name(node.fields, selector.field_name) or { + verror('ORM: field "${selector.field_name}" does not exist on "${g.sql_table_name}"') + } + g.writeln('for (${ast.int_type_name} ${idx_var} = 0; ${idx_var} < ${node.array_update_var}.len; ${idx_var}++) {') + g.indent++ + g.writeln('${row_type} ${row_var} = (*(${row_type}*)builtin__array_get(${node.array_update_var}, ${idx_var}));') + g.write('builtin__array_push(&${data_var}, _MOV((orm__Primitive[1]){') + g.write_orm_field_access_to_primitive(key_field, row_var, node.table_expr.typ, + g.table.sym(node.table_expr.typ)) + g.writeln('}));') + g.write('builtin__array_push(&${data_var}, _MOV((orm__Primitive[1]){') + g.write_orm_field_access_to_primitive(value_field, row_var, node.table_expr.typ, + g.table.sym(node.table_expr.typ)) + g.writeln('}));') + g.indent-- + g.writeln('}') + } + g.writeln('// sql { update `${table_name}` }') + g.writeln('${result_var_name} = orm__Connection_name_table[${connection_var_name}._typ]._method_update(') + g.indent++ + g.writeln('${connection_var_name}._object, // Connection object') + g.write_orm_table_struct(node.table_expr.typ) + g.writeln(',') + g.writeln('(orm__QueryData){') + g.indent++ + g.writeln('.fields = builtin__new_array_from_c_array(${node.updated_columns.len}, ${node.updated_columns.len}, sizeof(string),') + g.indent++ + g.writeln('_MOV((string[${node.updated_columns.len}]){') + g.indent++ + for field in node.updated_columns { + g.writeln('_S("${field}"),') + } + g.indent-- + g.writeln('})') + g.indent-- + g.writeln('),') + g.writeln('.data = ${data_var},') + g.writeln('.types = builtin____new_array_with_default_noscan(0, 0, sizeof(${ast.int_type_name}), 0),') + g.writeln('.auto_fields = builtin____new_array_with_default_noscan(0, 0, sizeof(${ast.int_type_name}), 0),') + g.writeln('.kinds = builtin____new_array_with_default_noscan(0, 0, sizeof(orm__OperationKind), 0),') + g.writeln('.is_and = builtin____new_array_with_default_noscan(0, 0, sizeof(bool), 0),') + g.writeln('.parentheses = builtin____new_array_with_default_noscan(0, 0, sizeof(Array_${ast.int_type_name}), 0),') + g.writeln('.batch_rows = ${node.array_update_var}.len,') + g.writeln('.batch_key = _S("${node.array_update_key}"),') + g.indent-- + g.writeln('},') + g.writeln('(orm__QueryData){') + g.indent++ + g.writeln('.fields = ${where_fields_var},') + g.writeln('.data = ${where_data_var},') + g.writeln('.types = builtin____new_array_with_default_noscan(0, 0, sizeof(${ast.int_type_name}), 0),') + g.writeln('.auto_fields = builtin____new_array_with_default_noscan(0, 0, sizeof(${ast.int_type_name}), 0),') + g.writeln('.kinds = ${where_kinds_var},') + g.writeln('.is_and = ${where_is_and_var},') + g.writeln('.parentheses = builtin____new_array_with_default_noscan(0, 0, sizeof(Array_${ast.int_type_name}), 0),') + g.indent-- + g.writeln('}') + g.indent-- + g.writeln(');') + g.indent-- + g.writeln('}') +} + +fn (mut g Gen) write_orm_field_access_to_primitive(field ast.StructField, object_var string, table_typ ast.Type, + inserting_object_sym &ast.TypeSymbol) { + final_field_typ := g.table.final_type(field.typ) + mut sym := g.table.sym(final_field_typ) + mut typ := g.orm_primitive_field_name(field.typ) + mut ctyp := sym.cname + if sym.kind == .struct && ctyp != 'time__Time' { + foreign_info := sym.info as ast.Struct + primary_field := g.get_orm_struct_primary_field(foreign_info.fields) or { + verror('ORM: struct field `${field.name}` of type `${sym.name}` has no primary field') + } + g.write_orm_field_access_to_primitive(primary_field, + '${object_var}.${orm_field_access_name(field.name)}', + final_field_typ.clear_flag(.option), g.table.sym(final_field_typ.clear_flag(.option))) + return + } + typ = vint2int(typ) + field_access := if inserting_object_sym.kind == .sum_type { + table_sym := g.table.sym(table_typ) + '(*${object_var}._${table_sym.cname}).${orm_field_access_name(field.name)}' + } else { + '${object_var}.${orm_field_access_name(field.name)}' + } + if final_field_typ.has_flag(.option) { + g.writeln('${field_access}.state == 2 ? _const_orm__null_primitive : orm__${typ}_to_primitive(*(${ctyp}*)(${field_access}.data)),') + } else { + g.writeln('orm__${typ}_to_primitive(${field_access}),') + } +} + +fn get_orm_field_by_struct_field_name(fields []ast.StructField, name string) ?ast.StructField { + for field in fields { + if field.name == name { + return field + } + } + return none +} + // write_orm_delete writes C code that calls ORM functions for deleting rows. fn (mut g Gen) write_orm_delete(node &ast.SqlStmtLine, table_name string, connection_var_name string, result_var_name string, _ []ast.Attr) { g.writeln('// sql { delete from `${table_name}` }') diff --git a/vlib/v/tests/orm_bulk_insert_update_test.v b/vlib/v/tests/orm_bulk_insert_update_test.v new file mode 100644 index 000000000..68c296b0f --- /dev/null +++ b/vlib/v/tests/orm_bulk_insert_update_test.v @@ -0,0 +1,194 @@ +import db.sqlite + +struct OrmBulkUser { + id int @[primary] + name string + age int +} + +struct OrmBulkDefaultRow { + id int @[primary; sql: serial] +} + +struct OrmBulkRenamedUser { + id int @[primary] + display_name string @[sql: 'display_name_text'] +} + +fn test_orm_bulk_insert_and_update() { + mut db := sqlite.connect(':memory:')! + defer { + db.close() or { panic(err) } + } + + sql db { + create table OrmBulkUser + }! + + users := [ + OrmBulkUser{ + id: 1 + name: 'Alice' + age: 25 + }, + OrmBulkUser{ + id: 2 + name: 'Bob' + age: 30 + }, + ] + + sql db { + insert users into OrmBulkUser + }! + + first := sql db { + select from OrmBulkUser where id == 1 + }! + second := sql db { + select from OrmBulkUser where id == 2 + }! + + assert first.len == 1 + assert first[0].name == 'Alice' + assert first[0].age == 25 + assert second.len == 1 + assert second[0].name == 'Bob' + assert second[0].age == 30 + + updates := [ + OrmBulkUser{ + id: 1 + name: 'Alicia' + age: 26 + }, + OrmBulkUser{ + id: 2 + name: 'Robert' + age: 31 + }, + ] + + sql db { + update OrmBulkUser set name = updates.name, age = updates.age where id == updates.id + }! + + updated_first := sql db { + select from OrmBulkUser where id == 1 + }! + updated_second := sql db { + select from OrmBulkUser where id == 2 + }! + + assert updated_first.len == 1 + assert updated_first[0].name == 'Alicia' + assert updated_first[0].age == 26 + assert updated_second.len == 1 + assert updated_second[0].name == 'Robert' + assert updated_second[0].age == 31 +} + +fn test_orm_bulk_insert_preserves_all_default_rows() { + mut db := sqlite.connect(':memory:')! + defer { + db.close() or { panic(err) } + } + + sql db { + create table OrmBulkDefaultRow + }! + + rows := [OrmBulkDefaultRow{}, OrmBulkDefaultRow{}, OrmBulkDefaultRow{}] + + sql db { + insert rows into OrmBulkDefaultRow + }! + + inserted := sql db { + select from OrmBulkDefaultRow order by id + }! + + assert inserted.len == 3 + assert inserted[0].id == 1 + assert inserted[1].id == 2 + assert inserted[2].id == 3 +} + +fn test_orm_bulk_insert_with_mixed_serial_values_keeps_defaults() { + mut db := sqlite.connect(':memory:')! + defer { + db.close() or { panic(err) } + } + + sql db { + create table OrmBulkDefaultRow + }! + + rows := [ + OrmBulkDefaultRow{}, + OrmBulkDefaultRow{ + id: 5 + }, + ] + + sql db { + insert rows into OrmBulkDefaultRow + }! + + inserted := sql db { + select from OrmBulkDefaultRow order by id + }! + + assert inserted.len == 2 + assert inserted[0].id == 1 + assert inserted[1].id == 5 +} + +fn test_orm_bulk_update_with_renamed_column() { + mut db := sqlite.connect(':memory:')! + defer { + db.close() or { panic(err) } + } + + sql db { + create table OrmBulkRenamedUser + }! + + users := [ + OrmBulkRenamedUser{ + id: 1 + display_name: 'Alice' + }, + OrmBulkRenamedUser{ + id: 2 + display_name: 'Bob' + }, + ] + + sql db { + insert users into OrmBulkRenamedUser + }! + + updates := [ + OrmBulkRenamedUser{ + id: 1 + display_name: 'Alicia' + }, + OrmBulkRenamedUser{ + id: 2 + display_name: 'Robert' + }, + ] + + sql db { + update OrmBulkRenamedUser set display_name = updates.display_name where id == updates.id + }! + + updated := sql db { + select from OrmBulkRenamedUser order by id + }! + + assert updated.len == 2 + assert updated[0].display_name == 'Alicia' + assert updated[1].display_name == 'Robert' +} -- 2.39.5