Files
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

383 lines
10 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package batch
import (
"context"
"database/sql"
"fmt"
"reflect"
"slices"
"sort"
"strings"
"time"
"github.com/gofrs/uuid"
"github.com/jmoiron/sqlx/reflectx"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/identity"
"github.com/ory/pop/v6"
"github.com/ory/x/dbal"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
)
type (
insertQueryArgs struct {
TableName string
ColumnsDecl string
Columns []string
Placeholders string
}
quoter interface {
Quote(key string) string
}
TracerConnection struct {
Tracer *otelx.Tracer
Connection *pop.Connection
}
// PartialConflictError represents a partial conflict during [Create]. It always
// wraps a [sqlcon.ErrUniqueViolation], so that the caller can either abort the
// whole transaction, or handle the partial success.
PartialConflictError[T any] struct {
Failed []*T
}
)
func (p *PartialConflictError[T]) Error() string {
return fmt.Sprintf("partial conflict error: %d models failed to insert", len(p.Failed))
}
func (p *PartialConflictError[T]) ErrOrNil() error {
if len(p.Failed) == 0 {
return nil
}
return p
}
func (p *PartialConflictError[T]) Unwrap() error {
if len(p.Failed) == 0 {
return nil
}
return sqlcon.ErrUniqueViolation()
}
func buildInsertQueryArgs[T any](ctx context.Context, models []*T, opts *createOpts) insertQueryArgs {
var (
v T
model = pop.NewModel(v, ctx)
columns []string
quotedColumns []string
placeholders []string
placeholderRow []string
)
for _, col := range model.Columns().Cols {
columns = append(columns, col.Name)
placeholderRow = append(placeholderRow, "?")
}
// We sort for the sole reason that the test snapshots are deterministic.
sort.Strings(columns)
// Extra columns trail the sorted model columns; values are appended in
// the same order in buildInsertQueryValues.
for _, ec := range opts.extraColumns {
columns = append(columns, ec.K)
placeholderRow = append(placeholderRow, "?")
}
for _, col := range columns {
quotedColumns = append(quotedColumns, opts.quoter.Quote(col))
}
// We generate a list (for every row one) of VALUE statements here that
// will be substituted by their column values later:
//
// (?, ?, ?, ?),
// (?, ?, ?, ?),
// (?, ?, ?, ?)
for _, m := range models {
m := reflect.ValueOf(m)
pl := make([]string, len(placeholderRow))
copy(pl, placeholderRow)
// PostgreSQL-compatible databases can generate UUIDs using
// "gen_random_uuid()", which ends up in a VALUE statement of:
//
// (gen_random_uuid(), ?, ?, ?),
for k := range placeholderRow {
if columns[k] != "id" {
continue
}
field := opts.mapper.FieldByName(m, columns[k])
val, ok := field.Interface().(uuid.UUID)
if !ok {
continue
}
if val == uuid.Nil && dbal.IsPostgresCompatible(opts.dialect) && !opts.partialInserts {
pl[k] = "gen_random_uuid()"
break
}
}
placeholders = append(placeholders, fmt.Sprintf("(%s)", strings.Join(pl, ", ")))
}
return insertQueryArgs{
TableName: opts.quoter.Quote(model.TableName()),
ColumnsDecl: strings.Join(quotedColumns, ", "),
Columns: columns,
Placeholders: strings.Join(placeholders, ",\n"),
}
}
func buildInsertQueryValues[T any](columns []string, models []*T, opts *createOpts) (values []any, err error) {
for _, m := range models {
m := reflect.ValueOf(m)
now := opts.now()
// Model fields only — extra columns aren't on the struct.
modelColumnCount := len(columns) - len(opts.extraColumns)
for _, c := range columns[:modelColumnCount] {
field := opts.mapper.FieldByName(m, c)
switch c {
case "created_at":
if pop.IsZeroOfUnderlyingType(field.Interface()) {
field.Set(reflect.ValueOf(now))
}
case "updated_at":
field.Set(reflect.ValueOf(now))
case "id":
if field.Interface().(uuid.UUID) != uuid.Nil {
break // breaks switch, not for
} else if dbal.IsPostgresCompatible(opts.dialect) && !opts.partialInserts {
// This is a special case:
// 1. We're using a PostgreSQL-compatible database.
// 2. It's the primary key field ("ID")
// 3. A UUID was not yet set.
//
// If all these conditions meet, the VALUE statement will look as such:
//
// (gen_random_uuid(), ?, ?, ?, ...)
//
// For that reason, we do not add the ID value to the list of arguments,
// because one of the arguments is using a built-in and thus doesn't need a value.
continue // break switch, not for
}
id, err := uuid.NewV4()
if err != nil {
return nil, err
}
field.Set(reflect.ValueOf(id))
}
values = append(values, field.Interface())
// Special-handling for *sqlxx.NullTime: mapper.FieldByName sets this to a zero time.Time,
// but we want a nil pointer instead.
if i, ok := field.Interface().(*sqlxx.NullTime); ok {
if time.Time(*i).IsZero() {
field.Set(reflect.Zero(field.Type()))
}
}
}
for _, ec := range opts.extraColumns {
values = append(values, ec.V)
}
}
return values, nil
}
type createOpts struct {
partialInserts bool
dialect string
mapper *reflectx.Mapper
quoter quoter
now func() time.Time
extraColumns []identity.ExtraColumn
}
type CreateOpts func(*createOpts)
// WithExtraColumns appends fixed (K, V) columns to every row of the batch.
// All rows receive the same values; group rows by extra-column values first.
func WithExtraColumns(cols []identity.ExtraColumn) CreateOpts {
return func(o *createOpts) {
o.extraColumns = append(o.extraColumns, cols...)
}
}
// WithPartialInserts allows to insert only the models that do not conflict with
// an existing record. WithPartialInserts will also generate the IDs for the
// models before inserting them, so that the successful inserts can be correlated
// with the input models.
//
// In particular, WithPartialInserts does not work with MySQL, because it does
// not support the "RETURNING" clause.
//
// WithPartialInserts does not work with CockroachDB and gen_random_uuid(),
// because then the successful inserts cannot be correlated with the input
// models. Note: gen_random_uuid() will skip the UNIQUE constraint check, which
// needs to hit all regions in a distributed setup. Therefore, WithPartialInserts
// should not be used to insert models for only a single identity.
var WithPartialInserts CreateOpts = func(o *createOpts) {
o.partialInserts = true
}
func newCreateOpts(conn *pop.Connection, opts ...CreateOpts) *createOpts {
o := new(createOpts)
o.dialect = conn.Dialect.Name()
o.mapper = conn.TX.Mapper
o.quoter = conn.Dialect.(quoter)
o.now = func() time.Time { return time.Now().UTC().Truncate(time.Microsecond) }
for _, f := range opts {
f(o)
}
return o
}
// Create batch-inserts the given models into the database using a single INSERT
// statement. By default, the models are either all created or none. If
// [WithPartialInserts] is passed as an option, partial inserts are supported,
// and the models that could not be inserted are returned in an
// [PartialConflictError].
func Create[T any](ctx context.Context, p *TracerConnection, models []*T, opts ...CreateOpts) (err error) {
ctx, span := p.Tracer.Tracer().Start(ctx, "persistence.sql.batch.Create",
trace.WithAttributes(attribute.Int("count", len(models))))
defer otelx.End(span, &err)
if len(models) == 0 {
return nil
}
var v T
model := pop.NewModel(v, ctx)
conn := p.Connection
options := newCreateOpts(conn, opts...)
queryArgs := buildInsertQueryArgs(ctx, models, options)
values, err := buildInsertQueryValues(queryArgs.Columns, models, options)
if err != nil {
return err
}
var returningClause string
if conn.Dialect.Name() != dbal.DriverMySQL {
// PostgreSQL, CockroachDB, YugabyteDB, and SQLite support RETURNING.
if options.partialInserts {
returningClause = fmt.Sprintf("ON CONFLICT DO NOTHING RETURNING %s", model.IDField())
} else {
returningClause = fmt.Sprintf("RETURNING %s", model.IDField())
}
}
query := conn.Dialect.TranslateSQL(fmt.Sprintf(
"INSERT INTO %s (%s) VALUES\n%s\n%s",
queryArgs.TableName,
queryArgs.ColumnsDecl,
queryArgs.Placeholders,
returningClause,
))
rows, err := conn.TX.QueryContext(ctx, query, values...)
if err != nil {
return sqlcon.HandleError(err)
}
defer func() { _ = rows.Close() }()
// MySQL, which does not support RETURNING, also does not have ON CONFLICT DO
// NOTHING, meaning that MySQL will always fail the whole transaction on a single
// record conflict.
if conn.Dialect.Name() == dbal.DriverMySQL {
return nil
}
if options.partialInserts {
return handlePartialInserts(queryArgs, values, models, rows)
} else {
return handleFullInserts(models, rows)
}
}
func handleFullInserts[T any](models []*T, rows *sql.Rows) error {
// Hydrate the models from the RETURNING clause.
for i := 0; rows.Next(); i++ {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return errors.WithStack(err)
}
if err := setModelID(id, models[i]); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return sqlcon.HandleError(err)
}
return nil
}
func handlePartialInserts[T any](queryArgs insertQueryArgs, values []any, models []*T, rows *sql.Rows) error {
// Hydrate the models from the RETURNING clause.
idsInDB := make(map[uuid.UUID]struct{})
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return errors.WithStack(err)
}
idsInDB[id] = struct{}{}
}
if err := rows.Err(); err != nil {
return sqlcon.HandleError(err)
}
idIdx := slices.Index(queryArgs.Columns, "id")
if idIdx == -1 {
return errors.New("id column not found")
}
var idValues []uuid.UUID
for i := idIdx; i < len(values); i += len(queryArgs.Columns) {
idValues = append(idValues, values[i].(uuid.UUID))
}
var partialConflictError PartialConflictError[T]
for i, id := range idValues {
if _, ok := idsInDB[id]; !ok {
partialConflictError.Failed = append(partialConflictError.Failed, models[i])
} else {
if err := setModelID(id, models[i]); err != nil {
return err
}
}
}
return partialConflictError.ErrOrNil()
}
// setModelID sets the id field of the model to the id.
func setModelID(id uuid.UUID, model any) error {
el := reflect.ValueOf(model).Elem()
idField := el.FieldByName("ID")
if !idField.IsValid() {
return errors.New("model does not have a field named id")
}
idField.Set(reflect.ValueOf(id))
return nil
}