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 } // 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, `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, o.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, )) 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) { rows, err := d.sql.QueryContext(ctx, `SELECT `+objectColumns+` FROM garden_objects WHERE garden_id = ? ORDER BY z_index, id`, gardenID, ) 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 }