package store import ( "context" "database/sql" "errors" "fmt" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // objectColumns lists garden_objects columns in the order scanObject expects. const objectColumns = `id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm, rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes, version, created_at, updated_at` func scanObject(s scanner) (*domain.GardenObject, error) { var ( o domain.GardenObject plantable int64 snap int64 ) if err := s.Scan( &o.ID, &o.GardenID, &o.Kind, &o.Name, &o.Shape, &o.Points, &o.XCM, &o.YCM, &o.WidthCM, &o.HeightCM, &o.RotationDeg, &o.ZIndex, &plantable, &o.Color, &o.Props, &o.GridSizeCM, &snap, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt, ); err != nil { return nil, err } o.Plantable = plantable != 0 o.SnapToGrid = snap != 0 return &o, nil } // objectInsert inserts one garden_objects row and returns it. It is shared by // CreateObject and CopyGarden's in-transaction copy, with objectInsertArgs // supplying its parameters, so a column added to the table can't be wired into // one path and silently dropped from the other. const objectInsert = `INSERT INTO garden_objects (garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm, rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING ` + objectColumns // objectInsertArgs builds objectInsert's parameters, placing o in gardenID (which // is o's own garden on create, and the new garden when copying). func objectInsertArgs(gardenID int64, o *domain.GardenObject) []any { return []any{ gardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM, o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes, } } // CreateObject inserts a garden object (fields already validated by the service) // and returns the stored row. func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) { created, err := scanObject(d.sql.QueryRowContext(ctx, objectInsert, objectInsertArgs(o.GardenID, o)...)) if err != nil { return nil, fmt.Errorf("store: insert object: %w", err) } return created, nil } // GetObject returns the object with the given id, or domain.ErrNotFound. func (d *DB) GetObject(ctx context.Context, id int64) (*domain.GardenObject, error) { o, err := scanObject(d.sql.QueryRowContext(ctx, `SELECT `+objectColumns+` FROM garden_objects WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrNotFound } if err != nil { return nil, fmt.Errorf("store: get object: %w", err) } return o, nil } // ListObjectsForGarden returns a garden's objects, bottom-to-top (z_index then // id). Always a non-nil slice. func (d *DB) ListObjectsForGarden(ctx context.Context, gardenID int64) ([]domain.GardenObject, error) { return queryObjects(ctx, d.sql, `SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY z_index, id`, gardenID) } // queryObjects runs an object query and scans every row. The result set is fully // drained and the cursor closed before returning, so a caller inside a // transaction may write on the same connection afterwards. Always a non-nil // slice. func queryObjects(ctx context.Context, q queryer, query string, args ...any) ([]domain.GardenObject, error) { rows, err := q.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("store: list objects: %w", err) } defer rows.Close() objects := []domain.GardenObject{} for rows.Next() { o, err := scanObject(rows) if err != nil { return nil, fmt.Errorf("store: scan object: %w", err) } objects = append(objects, *o) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: iterate objects: %w", err) } return objects, nil } // UpdateObject applies a version-guarded update of all mutable columns (the // service merges partial patches onto the current row first). Returns the // updated row, or (current row, ErrVersionConflict) / ErrNotFound — the same // contract as UpdateGarden. func (d *DB) UpdateObject(ctx context.Context, o *domain.GardenObject) (*domain.GardenObject, error) { updated, err := scanObject(d.sql.QueryRowContext(ctx, `UPDATE garden_objects SET kind = ?, name = ?, shape = ?, points = ?, x_cm = ?, y_cm = ?, width_cm = ?, height_cm = ?, rotation_deg = ?, z_index = ?, plantable = ?, color = ?, props = ?, grid_size_cm = ?, snap_to_grid = ?, notes = ?, version = version + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND version = ? RETURNING `+objectColumns, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM, o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes, o.ID, o.Version, )) if errors.Is(err, sql.ErrNoRows) { current, gerr := d.GetObject(ctx, o.ID) if gerr != nil { return nil, gerr } return current, domain.ErrVersionConflict } if err != nil { return nil, fmt.Errorf("store: update object: %w", err) } return updated, nil } // DeleteObject removes an object; its plantings cascade via the FK. Returns // domain.ErrNotFound if no row was deleted. func (d *DB) DeleteObject(ctx context.Context, id int64) error { res, err := d.sql.ExecContext(ctx, `DELETE FROM garden_objects WHERE id = ?`, id) if err != nil { return fmt.Errorf("store: delete object: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("store: object delete rows: %w", err) } if n == 0 { return domain.ErrNotFound } return nil }