5

I have 3 main columns (Points, TotalPoints, DateCreated) in a table using Entity Framework.

Scenario - Think of the "Fun Arcade" where you earn tokens for playing games and can then exchange those tokens for gifts. So your "Points" count or token count will go up and down as you use the application.

What I want - I'd like to see if Entity Framework can calculate the "TotalPoints" column, so that I don't have to do it every time a new "Point" entity is added to the table.

I know I can create a calculated column value that computes values from the same row (ex. first name col + last name col => full name column) But I want to sum up all values from the "Points" column for that user, so that the last row inserted (based on the DateCreated column) has the sum of all the points for that user.

ex. Point entity

points 1, totalPoints 1, dateCreated

points 3, totalPoints 4, dateCreated

points 2, totalPoints 6, dateCreated

points -1, totalPoints 5, dateCreated

points 2, totalPoints 7, dateCreated

FYI - I can also remove points, as seen above, if the user exchanges them for a gift.

QUESTION - Is this possible with fluent API and EF Core or do I have to manually calculate it every time I insert a new Point entry?

EDIT - I can do it manually and it seems to work ok. But I only have a few rows so far, so not sure about the performance with a few thousand rows for each user?

Here is what I have done.

When inserting a new "Point" entity I can calculate the sum of the users "Points" like this.

var totalPoints = userFromRepo.UserPoints.Sum(p => p.Points);
var point = new UserPoint() { Points = 1, TotalPoints = totalPoints+1, UserId = userFromRepo.Id, DateCreated = DateTime.UtcNow, UserPointType = UserPointType.Tip };
_unitOfWork.Repository<UserPoint>().Add(point);

and when I fetch the user data on log in, including the points, I do fetch based on the last "Point" inserted. This saves any time of doing some type of .SUM() or calculation each time a user logs in.

Ex. of getting users total points on log in

user.UserPoints.OrderByDescending(p => p.CreatedDate).LastOrDefault().TotalPoints)
chuckd
  • 13,460
  • 29
  • 152
  • 331
  • 2
    Database tables have no inherent order - you could get the rows back in a different order - what would you expect `sum` to be then? – NetMage Oct 28 '21 at 22:03
  • I'm pretty sure a computed column can only look at other columns from the same row, it can't aggregate rows. You could achieve this with a *view*, which is pretty similar – Andrew Williamson Oct 28 '21 at 23:45
  • I'd only consider a view if I can keep it within EF, as I'm trying to avoid work outside the application itself, but I think my solution I posted under 'EDIT' will be suffiecient. A view might be a little overkill. – chuckd Oct 28 '21 at 23:48
  • Do not submit edits to answer questions, post your own answer to your own question – Caius Jard Oct 29 '21 at 06:43
  • Are 'points' always >= 0? If so perhaps you should use '.Max(p => p.TotalPoints)' instead of Sum(). If not then '.OrderByDescending(p => p.CreatedDate).First().TotalPoints'. I don't think you want to be running a sum on the whole table on every insert. PS: I think your LastOrDefault should be First... – shunty Oct 29 '21 at 07:51
  • Hi Shunty. Points can be 0 and the client/user can use points in the app to exchange for things, so the table might have a row with the 'Point' column with value -1 or -5 and that would make the 'TotalPoints' column lower in count than the previous entry. – chuckd Oct 29 '21 at 18:58
  • 3
    I really don't see how EF could calculate this for you. EF is about translating LINQ into SQL. Calculating this value would require very specific SQL, which in turn would require very specific model configuration. That's not going to happen. Another thing is that storing redundant information should always be avoided. It's a source of potential violations of data integrity. – Gert Arnold Oct 30 '21 at 08:46
  • Really not sure how the comment that suggested adding numbers in a different order might affect the outcome managed to get two upvotes :-/ – Simon_Weaver May 30 '23 at 07:14

6 Answers6

2

From a DDD point of view you should consider User as your aggregate root which means that the user is responsible for the validity of all data it contains. Therefore I would suggest you not to add UserPoints directly to the database through a repository but instead add it through the User (it has a reference to them already anyway).

It could look somewhat like this:

public class User
{
    private readonly List<UserPoint> _userPoints = new List<UserPoint>();

    ...

    public int Id { get; private set; }

    public int TotalPoints { get; private set; }

    public IReadOnlyCollection UserPoints => _userPoints;

    public void AddPoints(int amount, UserPointType pointType)
    {
         TotalPoints = TotalPoints + amount;

         var point = new UserPoint() 
         { 
              Points = amount, 
              TotalPoints = this.TotalPoints,
              UserId = this.Id, 
              DateCreated = DateTime.UtcNow, 
              UserPointType = pointType 
         };

         _userPoints.Add(point);      
    }
}

Doing it this way you can have a single integer on the user object which always represents the current amount of points a user has and still have a history of all "Transactions" of points ever made for this user in case you need to display them in detail somewhere else. This will be very efficient to query if you just need the TotalPoints a user has at the moment and because you are only adding points for a user through the AddPoints method the property cannot get out of sync.

Another (and in my opinion even more important) advantage of this is, that you can have the user do some additional checks in the future e.g. have the User ensure that no amount of points may be added that leads to TotalPoints being less than 0 (because a user cannot spend points which he does't have).

When an application gets larger and there a multiple places in your code where points are added to a user (e.g. when he gains points and somewhere different place when he spends his points) it is very important to have a single method for doing so on the user directly which performs all the checks. Otherwise you will propably end up duplicating a lot of code and potentially having problems changing the behaviour at all neccessary places when an additional rule shall be added in the future.

Nannanas
  • 591
  • 2
  • 8
1

Not quite sure why you're getting the DB to do the running sum, or storing it, as it can be easily calculated every time the data is displayed if you have some other needed info like "the date the points we're earned".. unless you have some use case where only data after some moment is displayed..

For the simple use case, all data is displayed:

var ps = context.Points.Where(p => p.UserId == blah).OrderBy(...)
var sum = 0;
foreach(var p in ps)
  Console.WriteLine($"{p.Points} {sum+=p.Points}");

For the partial use case, some data after date X is displayed and points from before seed the rolling sum:

var ps = context.Points.Where(p => p.UserId == blah && p.SomeDate >= blah2).OrderBy(...)
var sum = context.Points.Where(p => p.UserId == blah && p.SomeDate < blah2).Sum(p => p.Points);

foreach(var p in ps)
  Console.WriteLine($"{p.Points} {sum+=p.Points}");

If you want to use a view, perhaps:

CREATE VIEW PointsWithSum AS
  SELECT UserId, Points, SUM(Points) OVER(PARTITION BY UserId ORDER BY SomeDate)

Scaffold that and you'll get a read only db set/entity but you can wire it into other entities by adding nav properties to it so you can query like

context.PointsWithSum.Include(pws => pws.User)
  .Where(pws => pws.UserId == 123)

Or similar..

Note; code examples based on assumption you have a table Points (an integer) with columns UserId, Points and SomeDate, which EF mapped to an entity called Point with properties of User, Points and SomeDate

Caius Jard
  • 72,509
  • 5
  • 49
  • 80
  • Hi Caius. Like I said in my OP, it would be nice to have a total count of the points for me(dev) so taht if I need to look at the last entry (by date created) I can see the total point count. It just saves me time of running the calculation in SQL to get it and it seems like better record keeping overall. I am keeping the 'createdDate' as a field, so yes I'm storing it. I've edited my OP above to show that field in the Ex. – chuckd Oct 29 '21 at 19:05
  • Hi Caius. As for your example I'm not sure if your first ex. is that different than doing a .Sum on the users point column, as I fetch just the users Points from the table when he/she logs in. Generally speaking, think of this as the "Fun Archade" where you get tokens for different games, then you might take those tokens and go to the counter and give some of them to the guy in exchange for a small cheap gift. So your token count can go up and down. – chuckd Oct 29 '21 at 19:07
  • Hi Caius. Lastly, when I fetch the users points on log in, I don't do a .Sum() of the points, that is only done when a point is either added or removed. On a user log in, I just run the line above from my OP that gets the last created point entry based on date created. See my OP above. – chuckd Oct 29 '21 at 19:10
  • re *better record keeping overall* - a typical problem we have with storing computed values is if the values they're based on need changing.. If you're going up and down in terms of a balance, then it's a bit like an accounting system; most of the banking systems I've worked on have had the notion that the bank balance at any particular time i calcualted from the debits and credits rather then stored per transaction. There's potentially a checkpointing system so that a "closing blanace" can be stored at the end of some accounting period, but for this summing them from 0 every time may suffice – Caius Jard Oct 29 '21 at 19:38
  • Hi Caius. Thanks for the response. I'm trying to understand your response a little better, maybe you can follow up. I don't have to store the computed value, I just thought it would be easier on me (dev) to find the value in the row if I needed to look at it and it would be a little faster to get the total points instead of summing it up every time a user logs in. – chuckd Oct 29 '21 at 23:26
  • 1
    It's not something I'd worry about; logins are relatively rare (they don't happen hundreds of times per second per user) and databases are incredibly good at summing stuff up. You're not wrong though; it's an option to store the account balance along with a transaction, just as it's an option to work out the balance by summing all the debits and credits whenever it's needed. Many times in software dev you'll be faced with a choice that is, essentially, do you burn cpu resource or memory resource.. – Caius Jard Oct 30 '21 at 05:24
  • These aren't the only ways to skin the cat; you could have a trigger that updates a "current balance" column on the user record every time a points transaction is posted.. but generally it's important to note that we strive not to keep a context hanging around for a long time so it's not really EF's job to maintain the balance - you wouldn't load the user context at the start of some session and then hold it for an hour, adding positive and negative points as the user plays and have EF clock up and down as points records are slowly added. Better to just commit points records as and when and.. – Caius Jard Oct 30 '21 at 05:31
  • 1
    ..when we there is a balance query (the user requests their balance or the gift shop owner check sit to make sure they can pay for a toy), just sum all the points records fron the beginning of time. Summing will be such an infrequent thing, and databases so good at it that you should worry about performance of other things more. I can confidently assert that you've already spent more time wondering about it by writing this question than you database will ever burn adding these things up – Caius Jard Oct 30 '21 at 05:33
  • Hi Caius. I confidently assert you are correct. Thanks for the help! – chuckd Oct 31 '21 at 02:42
  • isn't this solution sort of relying on the database more than EF in itself? making a view to select and using multiple statements, so in the question 'have to manually calculate' sort is what is done in the suggestion still? I mean it totally solves the problem, but wasn't the question more could i get EF to do it for me or do i have to do so myself? example being of how to do it oneself? – T. Nielsen Nov 05 '21 at 16:42
0

It should be possible using scalar valued user defined function.

configuration:

entity.Property(p => p.TotalScore)
    .HasComputedColumnSql("dbo.CalculateUserScore([Id])");

When it comes to performance, it for sure will be worse because it's hard to calculate query plan for function.

IMO, you should calculate this in your app code.

Posio
  • 962
  • 4
  • 15
0

Okay you're using this:

user.UserPoints.OrderByDescending(p => p.CreatedDate).LastOrDefault().TotalPoints)

and it should really be this if you're auto incrementing row id:

user.UserPoints.LastOrDefault().TotalPoints

which you want to turn into an auto calculated column? WHY??? It's too much writing? You're having performance issues? There's no reasoning here... So you want it to look like this instead?:

user.UserPoints.TotalPoints

That's all you want??? Just make an extension method. It's not clear what you're trying to do.

Ok so maybe you are worried about pulling the last value of TotalPoints in the creation of the new row? Don't be, it's already loaded in memory.

QUESTION - Is this possible with fluent API and EF Core or do I have to manually calculate it every time I insert a new Point entry?

You're just making a ledger. New row. Points added, or negative points added, then add/remove the same number from TotalPoints each time. Grab the current value of TotalPoints and modify that for the new row. That's totally normal. Then add the row. Save. You're not really calculating here. You're just adding and removing rows from a ledger with a total balance, then returning the most recent balance.

You should not hit any performance issues at all doing this ever.

I'm not sure what you want, but I can write the extension methods for you to help make it so you write as little as possible?

8vtwo
  • 60
  • 7
  • Doesn't user @1186050 just want to know if it is possible to add new point plus running sum of users point, so that getting users current points is super simple, using EF instead of manually doing sums like suggested for instance by Caius Jard ? – T. Nielsen Nov 05 '21 at 16:47
0

I suggest using the not so explored Aggregate capability of Entity framework, like for instance in Your DbContext where i presume Your userId is a Guid

public UserPoint InsertPointsCount(Guid UserId, int points)
{
    var pointsSum =  Points
                       .Where(x => x.UserId == userId)
                       .Aggregate<UserPoint, int, int>(
                                    points,
                                    (sum, p) => sum + p.Points, 
                                    (sum) => sum
                                );
    return Points.Add(new UserPoint
    {
        Points = pointsSum,
        UserId = UserId,
        UserPointType = UserPointType.Tip
    }).Entity;
}
T. Nielsen
  • 835
  • 5
  • 18
  • official documentaiton: https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.aggregate?view=net-5.0 – T. Nielsen Nov 05 '21 at 16:35
  • Hi T. Nielson. Thanks for the post, I'll look at the link. But to answer your question, no, the userId is not a Guid. It's the primary key id that is generated by Sql Server. – chuckd Nov 05 '21 at 20:35
  • Also would you see any advantage to doing it your way (aggregate) vs summing up the column values like this. user.UserPoints.Sum(p => p.Points) and then using this value? – chuckd Nov 05 '21 at 20:39
  • Try it out and measure performance, the reason is performance :) – T. Nielsen Nov 08 '21 at 10:25
0
var totalPoints = userFromRepo.UserPoints.LastOrDefault();
var point = new UserPoint() { Points = 1, TotalPoints = totalPoints+1, UserId = userFromRepo.Id, DateCreated = DateTime.UtcNow, UserPointType = UserPointType.Tip };
_unitOfWork.Repository<UserPoint>().Add(point);
Ahmed Alayat
  • 172
  • 1
  • 1
  • 11