Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Entity manager #278

Open
qRoC opened this issue May 4, 2022 · 9 comments
Open

Add Entity manager #278

qRoC opened this issue May 4, 2022 · 9 comments

Comments

@qRoC
Copy link

qRoC commented May 4, 2022

The entity manager is an implementation of the Facade pattern based on rel.Changeset.

All entities have 3 states:

  • new: base state for new entity - not managed by the entity manager.
  • managed: entity in new state passed to the Persist method of the entity manager or loaded from the database using method from the entity manager like Find
  • removed: Entity must by removed after flush

Method Flush - commits changes for all managed entities in transaction by rel.Changeset, and reset rel.Changeset state.

Additionally:

  • Find method with id criteria (find by id) should check if entity is already loaded and return it if is true (do not make a new request)
  • Transactional write-behind allows to replace many Insert in code to one InsertAll
  • Lazy users can wrap in middleware with automatic call flush
  • Use one time ctx context.Conext

API

Persist(record interface{})

Make an entity managed and persistent.
The record will be inserted into the database as a result of the Flush operation.
Only for new entities

Remove(record interface{})

Removes an entity.
A removed entity will be removed from the database as a result of the Flush operation.

Refresh(ctx context.Context, record interface{}) error

Refreshes the persistent state of an entity from the database, overriding any local changes that have not yet been persisted.

Flush() error

Flushes all changes to an entities that have been queued up to now to the database.

Select methods from rel.Repository

Retrieve the records, persist them in the entity manager, and return to the user.

API may by changed like:

Find[T any](ctx context.Context, queriers ...Querier) (*T, error)

Example

Current way:

func (t Todos) MoveToBasket(ctx context.Context, repo rel.Repository, id int) {
	var todo todos.Todo
	if err := repo.Find(ctx, &todo, where.Eq("id", id)); err != nil {
		panic(err)
	}
	changesTodo = rel.NewChangeset(&todo)

	var basket todos.Category
	if err := repo.Find(ctx, &basket, where.Eq("name", "basket")); err != nil {
		panic(err)
	}
	changesBasket = rel.NewChangeset(&basket)

        // domain code
	todo.Category = basket
	basket.Total += 1

	repo.Transaction(ctx, func(ctx context.Context) error {
		err := repo.Update(c, &todo, changesTodo)
			if err != nil {
				return err
			}

        	err := repo.Update(c, &basket, changesBasket)
			if err != nil {
				return err
			}

			return nil
	})
	if err != nil {
		render(ctx, "Server error", 500)
	}

	render(ctx, todo, 200)
}

If you don't want to get a problems, you should always call Update in the same method where entity fetched. Very often it turns out the controller. Very often this place is the controller

Entity manager:

func (t Todos) DoSomething(ctx context.Context, em rel.EntityManager, id int) {
	var todo todos.Todo
	if err := em.Find(ctx, &todo, where.Eq("id", id)); err != nil {
		panic(err)
	}

	var basket todos.Category
	if err := em.Find(ctx, &basket, where.Eq("name", "basket")); err != nil {
		panic(err)
	}

	// domain code
	todo.Category = basket
	basket.Total += 1

	render(ctx, todo, 200)
}

func MiddlewareForLazyUsers(ctx iris.Context, repo rel.Repository) {
	em := rel.NewEntityManager(repo)

	// request dependency
	ctx.RegisterDependency(em)

	ctx.Next()

	err := em.Flush(ctx.Context())
	if err != nil {
		render(ctx, "Server error", 500)
	}
}

Improvements

  1. Dedicated repositories for entity, this will improve the user experience and allow use of generics:
  • rel.Repository: Universal repository(current implementation)
  • rel.EntityRepository[T any]: Repository for entity. Similar to rel.Repository, but with generics and managed by entity manager.
  • rel.EntityManager: Entity manager with methods:
    • Persist(record interface{})
    • Remove(record interface{})
    • Refresh(ctx context.Context, record interface{}) error
    • Flush() error
    • repository(...) any: internal method for rel.Repository[T any].
func (t Todos) DoSomething(ctx context.Context, em rel.EntityManager, id int) {
	todo, err := rel.Repository[todos.Todo](em).Find(ctx, where.Eq("id", id))
	if err != nil {
		panic(err)
	}

	basket, err := rel.Repository[todos.Category](em).Find(ctx, where.Eq("name", "basket"))
	if err != nil {
		panic(err)
	}

	// domain code
	todo.Category = basket
	basket.Total += 1

	render(ctx, todo, 200)
}
  1. User can implement own repository and register than in entity manager.
  2. Association managed by entity manager
  • when the primary key (id) is not zero. does not work for client side id (like UUID). Entity manager resolve this issue.

Summary

Entity Manager allows you to keep the domain clean, and removes the complexity of working with the database

@Fs02
Copy link
Member

Fs02 commented May 13, 2022

User can implement own repository and register than in entity manager

what do you mean by this? do you mean rel.EntityRepository is something that need to be implemented by user?

if I understand correctly, rel.EntityManager and rel.EntityRepository is a high level wrapper to rel.Repository, and to create this entity manager, we don't actually need to modify rel.Repository. is this correct understanding?

I think the proposal sounds interesting, but I'm not sure about the interest from other users. would you like to start with PoC first? I could give you access to a blank repository in this organization

@qRoC
Copy link
Author

qRoC commented May 16, 2022

what do you mean by this? do you mean rel.EntityRepository is something that need to be implemented by user?

No, can do, not must.

if I understand correctly, rel.EntityManager and rel.EntityRepository is a high level wrapper to rel.Repository, and to create this entity manager, we don't actually need to modify rel.Repository. is this correct understanding?

Yes!

would you like to start with PoC first?

I will try to do this (I will start in a personal repository), but due to the situation in my country, I can't give an estimate of the time.

@Fs02
Copy link
Member

Fs02 commented May 16, 2022

Great, Thank you

Hope you always safe and the situation can get better soon 🙏

@screwyprof
Copy link

screwyprof commented Dec 13, 2022

Sounds both interesting and frighting. With generics comes the power and responsibility. All these entity managers/generic repos come from Java/Net world. Such tools as Entity Framework or Hibernate have been honed for years and include ridiculous amount of work. Lots of optimisations, lots features. Golang on the other side has always strived to be a very straightforward and simple language. With all these new features people tend to bring the re-invented heavy enterprise wheel into the go world.

Proper transaction handling in Golang when more than one entity is used is a pain in the butt, but building all this abstractions to mitigate this problem doesn't really look go-ish to me. Though I have to admit it would be interesting to see how it all ends up.

@qRoC
Copy link
Author

qRoC commented Dec 13, 2022

Implemented as UOW in one big project - everything is fine.

Prototype:

type UnitOfWork interface {
	Context() context.Context

	// Persist make the entities managed and persistent.
	// An entity will be inserted into the database as a result of the
	// Flush operation.
	Persist(entities ...any)

	// Removes the entities.
	// A removed entity will be removed from the database as a result of the
	// Flush operation.
	Remove(entities ...any)

	// Attach existing entities.
	// An entity will be updated into the database as a result of the
	// Flush operation.
	Attach(entities ...any)

	// Detach entities from UOW, so Flush operation do nothing with this
	// entities.
	Detach(entities ...any)

	// Flushes all changes to an entities that have been queued up to now to
	// the database.
	Flush() error
}

type UnitOfWorkFactory func(ctx context.Context) UnitOfWork

Transaction handling is not a problem because they are created at N levels (1 - base level: http request/other entry point, 2... - domain):

func (c *Controller) action(ctx *fiber.Ctx) error {
	uow := c.uowFactory(ctx.UserContext())

	c.anyService1.RunMethod(uow, args...)
	c.anyService2.RunMethod(uow, args...)

	if err := uow.Flush(); err != nil { // first level transaction
		return internal server error
 	}
}`

@screwyprof
Copy link

Hi @qRoC,
Is it an open source project? When in comes to design, your UoW exposes a context as a part of its API. What is a use case for it? I’ve noticed the UoW Factory takes a context, does it mean it propagates it to the UoW implementation? Is it possible to pass the context as the first arg of every method of UoW instead? If a repository was used, would the service then take it as a param instead of UoW, and the repo would use it for further actions? Thanks.

@qRoC
Copy link
Author

qRoC commented Dec 14, 2022

Is it an open source project?

No, but implementation has less than 200 lines of code. The only thing is that you need a patch for rel - master...qRoC:rel:master (details: #308).

Is it possible to pass the context as the first arg of every method of UoW instead?

Persist, Remove , Attach, Detach methods change state of UOW, so ctx is only needed in Flush.

If a repository was used, would the service then take it as a param instead of UoW, and the repo would use it for further actions? Thanks.

You can change signature from Flush() error to Flush(ctx context.Context) any, but the UOW and the context is a states, and it's okay to combine them, this variant is the most optimal (you need just change ctx to uow). Examples:

Separated objects:

func (s *Server) RegisterUser(ctx context.Context) {
	uow := createUOW()

	s.userService.RegisterUser(ctx, uow, email, password) // too many arguments :(

	if err := uow.Flush(ctx); err != nil {
		return s.internalServerError()
	}
}

func (s *UserService) RegisterUser(ctx context.Context, uow persistence.UnitOfWork, email, password string) (User, error) {
	user, err := s.repo.findByEmail(ctx, uow, email) // too many arguments :(
	if err != nil {
		return nil, err
	}
	if user != nil {
		return user, ErrUserExists
	}

	uow.Persist(user)
}

func (s *RelUserRepository) findByEmail(ctx context.Context, uow persistence.UnitOfWork, email string) (domain.User, error) {
	user := new(domain.User)

	if err := repo.inner.Find(ctx, user, rel.Eq("email", email)); err != nil {
		if errors.Is(err, rel.ErrNotFound) {
			return nil, nil
		}

		return nil, err
	}

	uow.Attach(user)

	return user, nil
}

context in UOW:

func (s *Server) RegisterUser(ctx context.Context) {
	uow := createUOW()

	s.userService.RegisterUser(uow, email, password)

	if err := uow.Flush(); err != nil {
		return s.internalServerError()
	}
}

func (s *UserService) RegisterUser(uow persistence.UnitOfWork, email, password string) (User, error) {
	user, err := s.repo.findByEmail(uow, email)
	if err != nil {
		return nil, err
	}
	if user != nil {
		return user, ErrUserExists
	}

	uow.Persist(user)
}

func (s *RelUserRepository) findByEmail(uow persistence.UnitOfWork, email string) (domain.User, error) {
	user := new(domain.User)

	if err := repo.inner.Find(uow.Context(), user, rel.Eq("email", email)); err != nil {
		if errors.Is(err, rel.ErrNotFound) {
			return nil, nil
		}

		return nil, err
	}

	uow.Attach(user)

	return user, nil
}

@qRoC
Copy link
Author

qRoC commented Dec 14, 2022

I can provide a basic implementation of UOW, but a full-fledged one will depend on your tasks. For example, I use an additional abstraction for counter fields:

type NumberOp = uint8

const (
	// NumberOpNothing means that a value is not changed.
	NumberOpNothing NumberOp = 0
	// NumberOpSet means that a value must be set.
	NumberOpSet NumberOp = 1
	// NumberOpIncrement means that a value must be incremented by value.
	NumberOpIncrement NumberOp = 2
)

// Implementation of a counter with data to be able to persist in custom
// service (like Redis, Postgres).
type Number[T constraints.Number] struct {
	initValue    T
	currentValue T
	isIncrement  bool
}

func NewNumber[T constraints.Number](value T) Number[T] {
	return Number[T]{
		initValue:    value,
		currentValue: value,
		isIncrement:  true,
	}
}

func (number *Number[T]) Set(value T) {
	number.currentValue = value
	number.isIncrement = false
}

func (number *Number[T]) Increment(value T) {
	number.currentValue += value
}

func (number Number[T]) Get() T {
	return number.currentValue
}

func (number Number[T]) PersistInfo() (NumberOp, T) {
	if number.initValue == number.currentValue {
		return NumberOpNothing, number.currentValue
	}

	if number.isIncrement {
		return NumberOpIncrement, number.currentValue - number.initValue
	}

	return NumberOpSet, number.currentValue
}
type Post struct {
    Views Number[uint64]
}

func (s *BlogService) ShowPost(uow persistence.UnitOfWork, id identity.Value) (Post, error) {
	post, err := s.repo.findByID(uow, id)
	if err != nil {
		return nil, err
	}
	if post != nil {
		post.Views.Increment(1)
		// attached to UOW in `findByID`, so `Persist` is not needed.
	}

	return post, nil
}

@screwyprof
Copy link

screwyprof commented Dec 15, 2022

Hey @qRoC, thanks for your replies.

If I got it correctly, your proposal about EntityRepository:

rel.EntityRepository[T any]: Repository for entity. Similar to rel.Repository, but with generics and managed by entity manager.

is very similar to what DbContext from .Net does. Some typical examples can be found in MSDN

A DbContext instance represents a combination of the Unit Of Work and Repository patterns such that it can be used to query from a database and group together changes that will then be written back to the store as a unit.

It would be great to have it as part of rel, though I gather it may require much more work than it seems in the first place.

When it comes to your examples, there is more than one way to skin a cat and to implement a Unit of work in conjunction with a Repository pattern.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants