From 279dc45ef45147c955e8e3d9d392a9caa799dbe9 Mon Sep 17 00:00:00 2001 From: Marcelo Soares Date: Thu, 16 Jul 2026 00:11:18 -0300 Subject: [PATCH 1/4] docs: Document ORM upsert methods --- .../02-database/05-crud.md | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/docs/06-concepts/03-data-and-the-database/02-database/05-crud.md b/docs/06-concepts/03-data-and-the-database/02-database/05-crud.md index 980fe720..1158c66b 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/05-crud.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/05-crud.md @@ -12,7 +12,10 @@ For the following examples we will use this model: class: Company table: company fields: - name: String + name: String, unique + address: String? + employeeCount: int, default=0 + isActive: bool, default=true ``` :::note @@ -60,6 +63,81 @@ This is useful for idempotent operations where you want to insert data without f When using `ignoreConflicts` with models that have [non-persistent fields](./tables#non-persistent-fields), each row is inserted individually instead of in a single batch. This is necessary because the database cannot report which rows were skipped in a batch insert, making it impossible to correctly match non-persistent field values back to inserted rows. For large numbers of rows, this can cause performance issues. Consider removing non-persistent fields from the model or inserting in smaller batches. ::: +## Insert or update rows + +Use an upsert when you want to insert a row unless another row already has the same unique value. If a conflict occurs, the existing row is updated instead. The database performs the check and write as one atomic operation. + +Call `upsertRow` to upsert one row. The `conflictColumns` must identify a unique constraint or unique index in your database. In the example model, `name` is unique: + +```dart +var company = await Company.db.upsertRow( + session, + Company(name: 'Serverpod', employeeCount: 42), + conflictColumns: (t) => [t.name], +); +``` + +If no company named `Serverpod` exists, a new row is inserted. Otherwise, the existing row keeps its `id`, and its other persistent columns are updated with the values from the supplied object. By default, Serverpod updates every persistent column except the `id` and the columns in `conflictColumns`. + +The primary `id` column can also be the conflict target. This is useful when a model may represent either an existing row with an `id` or a new row without one: + +```dart +var company = await Company.db.upsertRow( + session, + Company(id: companyId, name: 'Serverpod', employeeCount: 42), + conflictColumns: (t) => [t.id], +); +``` + +### Choose which columns to update + +Use `updateColumns` to limit which columns change when a conflict occurs: + +```dart +var company = await Company.db.upsertRow( + session, + Company(name: 'Serverpod', employeeCount: 42), + conflictColumns: (t) => [t.name], + updateColumns: (t) => [t.employeeCount], +); +``` + +This inserts all persistent values for a new row. For an existing row, it updates only `employeeCount`. + +Use `updateWhere` to update a conflicting row only when the existing row matches a condition: + +```dart +var company = await Company.db.upsertRow( + session, + Company(name: 'Serverpod', employeeCount: 42), + conflictColumns: (t) => [t.name], + updateWhere: (t) => t.isActive.equals(true), +); +``` + +The return type of `upsertRow` is nullable. If an existing row conflicts but does not match `updateWhere`, the row is left unchanged, and the method returns `null`. + +### Upsert several rows + +Call `upsert` to insert or update several rows in a batch: + +```dart +var companies = await Company.db.upsert( + session, + [ + Company(name: 'Serverpod', employeeCount: 42), + Company(name: 'Example Ltd', employeeCount: 12), + ], + conflictColumns: (t) => [t.name], +); +``` + +The batch is atomic. If any write fails, none of the rows are changed. You can also pass a `Transaction` with the `transaction` parameter. + +The batch method supports the same `updateColumns` and `updateWhere` parameters as `upsertRow`. Rows rejected by `updateWhere` are not returned, so the result can contain fewer rows than the input. Set `noReturn` to `true` if you do not need the resulting rows. The operation still writes the rows but returns an empty list. + +For models with [non-persistent fields](./tables#non-persistent-fields), Serverpod preserves the input values of those fields on the returned objects. The fields do not participate in conflict detection or database updates. A batch may use individual upsert statements internally to match each non-persistent value with the correct result, while keeping the full operation atomic. + ## Read There are three different read operations available. From 7b224604549bbd17a735618ac1ad90f381c6ae89 Mon Sep 17 00:00:00 2001 From: Marcelo Soares Date: Tue, 21 Jul 2026 15:35:04 -0300 Subject: [PATCH 2/4] docs: Restructure the upsert section on the CRUD page Use a single-word heading in line with Create, Read, Update, and Delete, give the single-row case its own subsection so it mirrors the Create section, switch to gerund subheadings, and link the transactions and filtering pages instead of naming the concepts in passing. --- .../02-database/05-crud.md | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/06-concepts/03-data-and-the-database/02-database/05-crud.md b/docs/06-concepts/03-data-and-the-database/02-database/05-crud.md index 1158c66b..c76c5b4c 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/05-crud.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/05-crud.md @@ -63,11 +63,13 @@ This is useful for idempotent operations where you want to insert data without f When using `ignoreConflicts` with models that have [non-persistent fields](./tables#non-persistent-fields), each row is inserted individually instead of in a single batch. This is necessary because the database cannot report which rows were skipped in a batch insert, making it impossible to correctly match non-persistent field values back to inserted rows. For large numbers of rows, this can cause performance issues. Consider removing non-persistent fields from the model or inserting in smaller batches. ::: -## Insert or update rows +## Upsert -Use an upsert when you want to insert a row unless another row already has the same unique value. If a conflict occurs, the existing row is updated instead. The database performs the check and write as one atomic operation. +An upsert inserts a row, or updates the row that is already there when the insert would violate a unique constraint. The database runs the check and the write as a single atomic operation, so no other transaction can slip in between them. -Call `upsertRow` to upsert one row. The `conflictColumns` must identify a unique constraint or unique index in your database. In the example model, `name` is unique: +### Upserting a single row + +Call `upsertRow` to upsert one row. The `conflictColumns` parameter names the columns that decide whether a row already exists, and those columns must be covered by a unique constraint or unique index. In the example model, `name` is unique. ```dart var company = await Company.db.upsertRow( @@ -77,9 +79,9 @@ var company = await Company.db.upsertRow( ); ``` -If no company named `Serverpod` exists, a new row is inserted. Otherwise, the existing row keeps its `id`, and its other persistent columns are updated with the values from the supplied object. By default, Serverpod updates every persistent column except the `id` and the columns in `conflictColumns`. +If no company named `Serverpod` exists, a new row is inserted. Otherwise the existing row keeps its `id` and its remaining persistent columns are overwritten with the values from the supplied object. By default every persistent column is updated except `id` and the columns listed in `conflictColumns`. -The primary `id` column can also be the conflict target. This is useful when a model may represent either an existing row with an `id` or a new row without one: +The `id` column can be the conflict target as well, which is useful when the same code path handles an object that may or may not already have an id. ```dart var company = await Company.db.upsertRow( @@ -89,9 +91,9 @@ var company = await Company.db.upsertRow( ); ``` -### Choose which columns to update +### Choosing which columns to update -Use `updateColumns` to limit which columns change when a conflict occurs: +Pass `updateColumns` to limit which columns change when a conflict occurs. ```dart var company = await Company.db.upsertRow( @@ -102,9 +104,9 @@ var company = await Company.db.upsertRow( ); ``` -This inserts all persistent values for a new row. For an existing row, it updates only `employeeCount`. +A new row is still inserted with all of its persistent values. An existing row only has its `employeeCount` updated. -Use `updateWhere` to update a conflicting row only when the existing row matches a condition: +Pass `updateWhere` to update a conflicting row only when the row already in the database matches a [filter](./filtering). ```dart var company = await Company.db.upsertRow( @@ -115,11 +117,11 @@ var company = await Company.db.upsertRow( ); ``` -The return type of `upsertRow` is nullable. If an existing row conflicts but does not match `updateWhere`, the row is left unchanged, and the method returns `null`. +This is why `upsertRow` returns a nullable value. When a row conflicts but does not match `updateWhere`, it is left untouched and the method returns `null`. -### Upsert several rows +### Upserting several rows -Call `upsert` to insert or update several rows in a batch: +Call `upsert` to insert or update several rows in a batch. ```dart var companies = await Company.db.upsert( @@ -132,11 +134,11 @@ var companies = await Company.db.upsert( ); ``` -The batch is atomic. If any write fails, none of the rows are changed. You can also pass a `Transaction` with the `transaction` parameter. +This is an atomic operation, meaning no rows are written if any row fails. Pass a [transaction](./transactions) with the `transaction` parameter to make it part of a larger unit of work. -The batch method supports the same `updateColumns` and `updateWhere` parameters as `upsertRow`. Rows rejected by `updateWhere` are not returned, so the result can contain fewer rows than the input. Set `noReturn` to `true` if you do not need the resulting rows. The operation still writes the rows but returns an empty list. +The batch method takes the same `updateColumns` and `updateWhere` parameters as `upsertRow`. Rows rejected by `updateWhere` are left out of the result, so the returned list can be shorter than the input. Set `noReturn` to `true` to write the rows without reading them back. -For models with [non-persistent fields](./tables#non-persistent-fields), Serverpod preserves the input values of those fields on the returned objects. The fields do not participate in conflict detection or database updates. A batch may use individual upsert statements internally to match each non-persistent value with the correct result, while keeping the full operation atomic. +For models with [non-persistent fields](./tables#non-persistent-fields), the input values of those fields are carried over to the returned objects. They take no part in conflict detection and are never written to the database. ## Read From fc03e73c085a12506b615ae9d6ac22e21fd36e4b Mon Sep 17 00:00:00 2001 From: developerjamiu Date: Wed, 22 Jul 2026 18:26:53 +0100 Subject: [PATCH 3/4] docs: Absorb the remaining upsert facts from the original section --- .../03-data-and-the-database/02-database/02-crud.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md b/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md index 4522c486..3a9ab462 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md @@ -239,7 +239,7 @@ var product = await Product.db.upsertRow( ); ``` -To limit which columns an update touches, pass `updateColumns`. Columns outside the selection keep their stored values: +To limit which columns an update touches, pass `updateColumns`. A new row is still inserted with all of its values, and an existing row only has the selected columns updated: ```dart var product = await Product.db.upsertRow( @@ -280,7 +280,7 @@ var products = await Product.db.upsert( ); ``` -The result contains one row per input, in the same order. When `updateWhere` is set, conflicting rows that do not match are skipped and left out of the result, so the list can be shorter than the input. For large batches, the read-back can be skipped entirely. See [Skipping returned rows](#skipping-returned-rows). +The batch method takes the same `updateColumns` and `updateWhere` parameters as `upsertRow`. The result contains one row per input, in the same order. When `updateWhere` is set, conflicting rows that do not match are skipped and left out of the result, so the list can be shorter than the input. For large batches, the read-back can be skipped entirely. See [Skipping returned rows](#skipping-returned-rows). Like the other batch operations, `upsert` runs atomically and accepts a `transaction` parameter to join a larger [transaction](transactions). For models with [non-persistent fields](tables#non-persistent-fields), the input values of those fields are carried over to the returned objects. They take no part in conflict detection and are never written to the database, and such batches are upserted row by row internally, which can be slow for large inputs. From 4ebbac80ffbaa300966f901be3c5cfcfe63ee7ef Mon Sep 17 00:00:00 2001 From: developerjamiu Date: Wed, 22 Jul 2026 18:36:13 +0100 Subject: [PATCH 4/4] docs: Remove duplicated phrasing in the merged upsert section --- .../03-data-and-the-database/02-database/02-crud.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md b/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md index 3a9ab462..77226978 100644 --- a/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md +++ b/docs/06-concepts/03-data-and-the-database/02-database/02-crud.md @@ -227,7 +227,7 @@ var product = await Product.db.upsertRow( ); ``` -If no row with that `sku` exists, the row is inserted. If one exists, it keeps its `id` and its remaining persistent columns are overwritten with the supplied values: every persistent column is updated except `id` and the columns in `conflictColumns`. The method returns the stored row. +If no row with that `sku` exists, the row is inserted. If one exists, it keeps its `id`, and every persistent column except `id` and the `conflictColumns` is overwritten with the supplied values. The method returns the stored row. The `id` column can be the conflict target as well, which is useful when the same code path handles an object that may or may not already have an id: @@ -267,7 +267,7 @@ The literal `500.0` matches the `double` column type. Comparison operators check ### Upsert several rows -The batch `upsert` inserts and updates rows in a single atomic operation: +The batch `upsert` inserts and updates rows in a single atomic operation, so no rows are written if any row fails: ```dart var products = await Product.db.upsert( @@ -282,7 +282,7 @@ var products = await Product.db.upsert( The batch method takes the same `updateColumns` and `updateWhere` parameters as `upsertRow`. The result contains one row per input, in the same order. When `updateWhere` is set, conflicting rows that do not match are skipped and left out of the result, so the list can be shorter than the input. For large batches, the read-back can be skipped entirely. See [Skipping returned rows](#skipping-returned-rows). -Like the other batch operations, `upsert` runs atomically and accepts a `transaction` parameter to join a larger [transaction](transactions). For models with [non-persistent fields](tables#non-persistent-fields), the input values of those fields are carried over to the returned objects. They take no part in conflict detection and are never written to the database, and such batches are upserted row by row internally, which can be slow for large inputs. +Like the other batch operations, `upsert` accepts a `transaction` parameter to join a larger [transaction](transactions). For models with [non-persistent fields](tables#non-persistent-fields), the input values of those fields are carried over to the returned objects. They take no part in conflict detection and are never written to the database, and such batches are upserted row by row internally, which can be slow for large inputs. A single-row upsert that unexpectedly matches multiple rows throws a `DatabaseUpsertRowException`. See [exceptions](exceptions).