From 3aab8bf4d514c8cd9624d0bc1b417535dec9c2bd Mon Sep 17 00:00:00 2001 From: Grady Berry Ward Date: Mon, 18 Dec 2023 16:29:31 -0700 Subject: [PATCH] Backend for Portfolio Groups (#83) --- README.md | 2 +- cmd/runner/README.md | 2 +- cmd/server/pactasrv/BUILD.bazel | 2 + cmd/server/pactasrv/conv/oapi_to_pacta.go | 17 ++ cmd/server/pactasrv/conv/pacta_to_oapi.go | 40 ++++ cmd/server/pactasrv/pactasrv.go | 10 + cmd/server/pactasrv/populate.go | 109 +++++++++++ cmd/server/pactasrv/portfolio.go | 14 +- cmd/server/pactasrv/portfolio_group.go | 136 ++++++++++++++ db/sqldb/portfolio.go | 110 +++++++---- db/sqldb/portfolio_group.go | 97 ++++++---- db/sqldb/portfolio_group_test.go | 57 ++++++ db/sqldb/sqldb.go | 8 + openapi/pacta.yaml | 211 ++++++++++++++++++++++ pacta/pacta.go | 2 + 15 files changed, 735 insertions(+), 82 deletions(-) create mode 100644 cmd/server/pactasrv/populate.go create mode 100644 cmd/server/pactasrv/portfolio_group.go diff --git a/README.md b/README.md index b367a89..7bee5bc 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ bazel run //scripts:run_server -- --use_azure_auth bazel run //scripts:run_db # In another terminal, run the PACTA server -bazel run //scripts:run_server +bazel run //scripts:run_server -- --with_public_endpoint=$USER # In one last terminal, run the frontend cd frontend diff --git a/cmd/runner/README.md b/cmd/runner/README.md index c0dfd91..4b6f15b 100644 --- a/cmd/runner/README.md +++ b/cmd/runner/README.md @@ -10,7 +10,7 @@ If you do want to actually run the full `runner` image on Azure, you can use: ```bash # Run the backend, tell it to create tasks as real Azure Container Apps Jobs. -bazel run //scripts:run_apiserver -- --use_azure_runner +bazel run //scripts:run_server -- --use_azure_runner ``` ### Creating a new docker image to run locally diff --git a/cmd/server/pactasrv/BUILD.bazel b/cmd/server/pactasrv/BUILD.bazel index ee7f21b..43c06f6 100644 --- a/cmd/server/pactasrv/BUILD.bazel +++ b/cmd/server/pactasrv/BUILD.bazel @@ -10,7 +10,9 @@ go_library( "pacta_version.go", "pactasrv.go", "parallel.go", + "populate.go", "portfolio.go", + "portfolio_group.go", "upload.go", "user.go", ], diff --git a/cmd/server/pactasrv/conv/oapi_to_pacta.go b/cmd/server/pactasrv/conv/oapi_to_pacta.go index 7d0fb06..32d39f9 100644 --- a/cmd/server/pactasrv/conv/oapi_to_pacta.go +++ b/cmd/server/pactasrv/conv/oapi_to_pacta.go @@ -75,3 +75,20 @@ func HoldingsDateFromOAPI(hd *api.HoldingsDate) (*pacta.HoldingsDate, error) { Time: hd.Time, }, nil } + +func PortfolioGroupCreateFromOAPI(pg *api.PortfolioGroupCreate, ownerID pacta.OwnerID) (*pacta.PortfolioGroup, error) { + if pg == nil { + return nil, oapierr.Internal("portfolioGroupCreateFromOAPI: can't convert nil pointer") + } + if pg.Name == "" { + return nil, oapierr.BadRequest("name must not be empty") + } + if ownerID == "" { + return nil, oapierr.Internal("portfolioGroupCreateFromOAPI: ownerID must not be empty") + } + return &pacta.PortfolioGroup{ + Name: pg.Name, + Description: pg.Description, + Owner: &pacta.Owner{ID: ownerID}, + }, nil +} diff --git a/cmd/server/pactasrv/conv/pacta_to_oapi.go b/cmd/server/pactasrv/conv/pacta_to_oapi.go index 6bf6c9d..dc27050 100644 --- a/cmd/server/pactasrv/conv/pacta_to_oapi.go +++ b/cmd/server/pactasrv/conv/pacta_to_oapi.go @@ -142,6 +142,17 @@ func PortfolioToOAPI(p *pacta.Portfolio) (*api.Portfolio, error) { if err != nil { return nil, oapierr.Internal("portfolioToOAPI: holdingsDateToOAPI failed", zap.Error(err)) } + memberOfs := []api.PortfolioGroupMembershipPortfolioGroup{} + for _, m := range p.MemberOf { + pg, err := PortfolioGroupToOAPI(m.PortfolioGroup) + if err != nil { + return nil, oapierr.Internal("portfolioToOAPI: portfolioGroupToOAPI failed", zap.Error(err)) + } + memberOfs = append(memberOfs, api.PortfolioGroupMembershipPortfolioGroup{ + CreatedAt: m.CreatedAt, + PortfolioGroup: *pg, + }) + } return &api.Portfolio{ Id: string(p.ID), Name: p.Name, @@ -150,5 +161,34 @@ func PortfolioToOAPI(p *pacta.Portfolio) (*api.Portfolio, error) { CreatedAt: p.CreatedAt, NumberOfRows: p.NumberOfRows, AdminDebugEnabled: p.AdminDebugEnabled, + Groups: &memberOfs, + }, nil +} + +func PortfolioGroupToOAPI(pg *pacta.PortfolioGroup) (*api.PortfolioGroup, error) { + if pg == nil { + return nil, oapierr.Internal("portfolioGroupToOAPI: can't convert nil pointer") + } + members := []api.PortfolioGroupMembershipPortfolio{} + for _, m := range pg.Members { + portfolio, err := PortfolioToOAPI(m.Portfolio) + if err != nil { + return nil, oapierr.Internal("portfolioGroupToOAPI: portfolioToOAPI failed", zap.Error(err)) + } + members = append(members, api.PortfolioGroupMembershipPortfolio{ + CreatedAt: m.CreatedAt, + Portfolio: *portfolio, + }) + } + return &api.PortfolioGroup{ + Id: string(pg.ID), + Name: pg.Name, + Description: pg.Description, + CreatedAt: pg.CreatedAt, + Members: &members, }, nil } + +func PortfolioGroupsToOAPI(pgs []*pacta.PortfolioGroup) ([]*api.PortfolioGroup, error) { + return convAll(pgs, PortfolioGroupToOAPI) +} diff --git a/cmd/server/pactasrv/pactasrv.go b/cmd/server/pactasrv/pactasrv.go index 8b40623..1730401 100644 --- a/cmd/server/pactasrv/pactasrv.go +++ b/cmd/server/pactasrv/pactasrv.go @@ -70,6 +70,7 @@ type DB interface { Portfolio(tx db.Tx, id pacta.PortfolioID) (*pacta.Portfolio, error) PortfoliosByOwner(tx db.Tx, owner pacta.OwnerID) ([]*pacta.Portfolio, error) + Portfolios(tx db.Tx, ids []pacta.PortfolioID) (map[pacta.PortfolioID]*pacta.Portfolio, error) CreatePortfolio(tx db.Tx, i *pacta.Portfolio) (pacta.PortfolioID, error) UpdatePortfolio(tx db.Tx, id pacta.PortfolioID, mutations ...db.UpdatePortfolioFn) error DeletePortfolio(tx db.Tx, id pacta.PortfolioID) ([]pacta.BlobURI, error) @@ -83,6 +84,15 @@ type DB interface { GetOrCreateOwnerForUser(tx db.Tx, uID pacta.UserID) (pacta.OwnerID, error) + PortfolioGroup(tx db.Tx, id pacta.PortfolioGroupID) (*pacta.PortfolioGroup, error) + PortfolioGroupsByOwner(tx db.Tx, owner pacta.OwnerID) ([]*pacta.PortfolioGroup, error) + PortfolioGroups(tx db.Tx, ids []pacta.PortfolioGroupID) (map[pacta.PortfolioGroupID]*pacta.PortfolioGroup, error) + CreatePortfolioGroup(tx db.Tx, p *pacta.PortfolioGroup) (pacta.PortfolioGroupID, error) + UpdatePortfolioGroup(tx db.Tx, id pacta.PortfolioGroupID, mutations ...db.UpdatePortfolioGroupFn) error + DeletePortfolioGroup(tx db.Tx, id pacta.PortfolioGroupID) error + CreatePortfolioGroupMembership(tx db.Tx, pgID pacta.PortfolioGroupID, pID pacta.PortfolioID) error + DeletePortfolioGroupMembership(tx db.Tx, pgID pacta.PortfolioGroupID, pID pacta.PortfolioID) error + GetOrCreateUserByAuthn(tx db.Tx, mech pacta.AuthnMechanism, authnID, email, canonicalEmail string) (*pacta.User, error) User(tx db.Tx, id pacta.UserID) (*pacta.User, error) Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, error) diff --git a/cmd/server/pactasrv/populate.go b/cmd/server/pactasrv/populate.go new file mode 100644 index 0000000..7ee5af4 --- /dev/null +++ b/cmd/server/pactasrv/populate.go @@ -0,0 +1,109 @@ +package pactasrv + +import ( + "context" + "fmt" + + "github.com/RMI/pacta/oapierr" + "github.com/RMI/pacta/pacta" + "go.uber.org/zap" +) + +func (s *Server) populatePortfoliosInPortfolioGroups( + ctx context.Context, + ts []*pacta.PortfolioGroup, +) error { + getFn := func(pg *pacta.PortfolioGroup) ([]*pacta.Portfolio, error) { + result := []*pacta.Portfolio{} + for _, member := range pg.Members { + result = append(result, member.Portfolio) + } + return result, nil + } + lookupFn := func(ids []pacta.PortfolioID) (map[pacta.PortfolioID]*pacta.Portfolio, error) { + return s.DB.Portfolios(s.DB.NoTxn(ctx), ids) + } + getIDFn := func(p *pacta.Portfolio) pacta.PortfolioID { + return p.ID + } + if err := populateAll(ts, getFn, getIDFn, lookupFn); err != nil { + return oapierr.Internal("populating portfolios in portfolio groups failed", zap.Error(err)) + } + return nil +} + +func (s *Server) populatePortfolioGroupsInPortfolios( + ctx context.Context, + ts []*pacta.Portfolio, +) error { + getFn := func(pg *pacta.Portfolio) ([]*pacta.PortfolioGroup, error) { + result := []*pacta.PortfolioGroup{} + for _, member := range pg.MemberOf { + result = append(result, member.PortfolioGroup) + } + return result, nil + } + lookupFn := func(ids []pacta.PortfolioGroupID) (map[pacta.PortfolioGroupID]*pacta.PortfolioGroup, error) { + return s.DB.PortfolioGroups(s.DB.NoTxn(ctx), ids) + } + getIDFn := func(p *pacta.PortfolioGroup) pacta.PortfolioGroupID { + return p.ID + } + if err := populateAll(ts, getFn, getIDFn, lookupFn); err != nil { + return oapierr.Internal("populating portfolio groups in portfolios failed", zap.Error(err)) + } + return nil +} + +// This helper function populates the given targets in the given sources, +// to allow for generic population of nested data structures. +// sources = entities that you want to populate sub-entity references in. +// the sub-entities should be pointers to structs with an ID populated. +// getTargetsFn = function that takes a source and returns zero or more sub-entities to populate. +// getTargetIDFn = function that takes a sub-entity and returns its ID. +// lookupTargetsFn = function that takes a list of sub-entity IDs and returns a map of ID -> sub-entity. +func populateAll[Source any, TargetID ~string, Target any]( + sources []*Source, + getTargetsFn func(*Source) ([]*Target, error), + getTargetIDFn func(*Target) TargetID, + lookupTargetsFn func([]TargetID) (map[TargetID]*Target, error), +) error { + allTargets := []*Target{} + for i, source := range sources { + targets, err := getTargetsFn(source) + if err != nil { + return fmt.Errorf("getting %d-th targets: %w", i, err) + } + allTargets = append(allTargets, targets...) + } + + seen := map[TargetID]bool{} + uniqueIds := []TargetID{} + for _, target := range allTargets { + id := getTargetIDFn(target) + if _, ok := seen[id]; !ok { + uniqueIds = append(uniqueIds, id) + seen[id] = true + } + } + + populatedTargets, err := lookupTargetsFn(uniqueIds) + if err != nil { + return fmt.Errorf("looking up populated: %w", err) + } + for i, source := range sources { + targets, err := getTargetsFn(source) + if err != nil { + return fmt.Errorf("re-getting %d-th targets: %w", i, err) + } + for _, target := range targets { + id := getTargetIDFn(target) + if populated, ok := populatedTargets[id]; ok { + *target = *populated + } else { + return fmt.Errorf("can't find populated target %s", id) + } + } + } + return nil +} diff --git a/cmd/server/pactasrv/portfolio.go b/cmd/server/pactasrv/portfolio.go index b33a743..8520d3e 100644 --- a/cmd/server/pactasrv/portfolio.go +++ b/cmd/server/pactasrv/portfolio.go @@ -20,11 +20,14 @@ func (s *Server) ListPortfolios(ctx context.Context, request api.ListPortfoliosR if err != nil { return nil, err } - ius, err := s.DB.PortfoliosByOwner(s.DB.NoTxn(ctx), ownerID) + ps, err := s.DB.PortfoliosByOwner(s.DB.NoTxn(ctx), ownerID) if err != nil { return nil, oapierr.Internal("failed to query portfolios", zap.Error(err)) } - items, err := dereference(conv.PortfoliosToOAPI(ius)) + if err := s.populatePortfolioGroupsInPortfolios(ctx, ps); err != nil { + return nil, err + } + items, err := dereference(conv.PortfoliosToOAPI(ps)) if err != nil { return nil, err } @@ -52,11 +55,14 @@ func (s *Server) DeletePortfolio(ctx context.Context, request api.DeletePortfoli // Returns an portfolio by ID // (GET /portfolio/{id}) func (s *Server) FindPortfolioById(ctx context.Context, request api.FindPortfolioByIdRequestObject) (api.FindPortfolioByIdResponseObject, error) { - iu, err := s.checkPortfolioAuthorization(ctx, pacta.PortfolioID(request.Id)) + p, err := s.checkPortfolioAuthorization(ctx, pacta.PortfolioID(request.Id)) if err != nil { return nil, err } - converted, err := conv.PortfolioToOAPI(iu) + if err := s.populatePortfolioGroupsInPortfolios(ctx, []*pacta.Portfolio{p}); err != nil { + return nil, err + } + converted, err := conv.PortfolioToOAPI(p) if err != nil { return nil, err } diff --git a/cmd/server/pactasrv/portfolio_group.go b/cmd/server/pactasrv/portfolio_group.go new file mode 100644 index 0000000..b21a42d --- /dev/null +++ b/cmd/server/pactasrv/portfolio_group.go @@ -0,0 +1,136 @@ +package pactasrv + +import ( + "context" + + "github.com/RMI/pacta/cmd/server/pactasrv/conv" + "github.com/RMI/pacta/db" + "github.com/RMI/pacta/oapierr" + api "github.com/RMI/pacta/openapi/pacta" + "github.com/RMI/pacta/pacta" + "go.uber.org/zap" +) + +// Returns a portfolio group by ID +// (GET /portfolio-group/{id}) +func (s *Server) FindPortfolioGroupById(ctx context.Context, request api.FindPortfolioGroupByIdRequestObject) (api.FindPortfolioGroupByIdResponseObject, error) { + // TODO(#12) Implement Authorization + pg, err := s.DB.PortfolioGroup(s.DB.NoTxn(ctx), pacta.PortfolioGroupID(request.Id)) + if err != nil { + if db.IsNotFound(err) { + return nil, oapierr.NotFound("portfolio group not found", zap.String("portfolio_group_id", request.Id)) + } + return nil, oapierr.Internal("failed to load portfolio_group", zap.String("portfolio_group_id", request.Id), zap.Error(err)) + } + if err := s.populatePortfoliosInPortfolioGroups(ctx, []*pacta.PortfolioGroup{pg}); err != nil { + return nil, err + } + resp, err := conv.PortfolioGroupToOAPI(pg) + if err != nil { + return nil, err + } + return api.FindPortfolioGroupById200JSONResponse(*resp), nil +} + +// Returns the portfolio groups that the user has access to +// (GET /portfolio-groups) +func (s *Server) ListPortfolioGroups(ctx context.Context, request api.ListPortfolioGroupsRequestObject) (api.ListPortfolioGroupsResponseObject, error) { + // TODO(#12) Implement Authorization + ownerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + pgs, err := s.DB.PortfolioGroupsByOwner(s.DB.NoTxn(ctx), ownerID) + if err != nil { + return nil, oapierr.Internal("failed to query portfolio groups", zap.Error(err)) + } + if err := s.populatePortfoliosInPortfolioGroups(ctx, pgs); err != nil { + return nil, err + } + items, err := dereference(conv.PortfolioGroupsToOAPI(pgs)) + if err != nil { + return nil, err + } + return api.ListPortfolioGroups200JSONResponse{Items: items}, nil +} + +// Creates a portfolio group +// (POST /portfolio-groups) +func (s *Server) CreatePortfolioGroup(ctx context.Context, request api.CreatePortfolioGroupRequestObject) (api.CreatePortfolioGroupResponseObject, error) { + ownerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + pg, err := conv.PortfolioGroupCreateFromOAPI(request.Body, ownerID) + if err != nil { + return nil, err + } + id, err := s.DB.CreatePortfolioGroup(s.DB.NoTxn(ctx), pg) + if err != nil { + return nil, oapierr.Internal("failed to create portfolio group", zap.Error(err)) + } + pg.ID = id + pg.CreatedAt = s.Now() + resp, err := conv.PortfolioGroupToOAPI(pg) + if err != nil { + return nil, err + } + return api.CreatePortfolioGroup200JSONResponse(*resp), nil +} + +// Updates portfolio group properties +// (PATCH /portfolio-group/{id}) +func (s *Server) UpdatePortfolioGroup(ctx context.Context, request api.UpdatePortfolioGroupRequestObject) (api.UpdatePortfolioGroupResponseObject, error) { + // TODO(#12) Implement Authorization + id := pacta.PortfolioGroupID(request.Id) + mutations := []db.UpdatePortfolioGroupFn{} + b := request.Body + if b.Name != nil { + mutations = append(mutations, db.SetPortfolioGroupName(*b.Name)) + } + if b.Description != nil { + mutations = append(mutations, db.SetPortfolioGroupDescription(*b.Description)) + } + err := s.DB.UpdatePortfolioGroup(s.DB.NoTxn(ctx), id, mutations...) + if err != nil { + return nil, oapierr.Internal("failed to update portfolio group", zap.String("portfolio_group_id", string(id)), zap.Error(err)) + } + return api.UpdatePortfolioGroup204Response{}, nil +} + +// Deletes a portfolio group by ID +// (DELETE /portfolio-group/{id}) +func (s *Server) DeletePortfolioGroup(ctx context.Context, request api.DeletePortfolioGroupRequestObject) (api.DeletePortfolioGroupResponseObject, error) { + // TODO(#12) Implement Authorization + err := s.DB.DeletePortfolioGroup(s.DB.NoTxn(ctx), pacta.PortfolioGroupID(request.Id)) + if err != nil { + return nil, oapierr.Internal("failed to delete portfolio group", zap.String("portfolio_group_id", request.Id), zap.Error(err)) + } + return api.DeletePortfolioGroup204Response{}, nil +} + +// Deletes a portfolio group membership +// (DELETE /portfolio-group-membership) +func (s *Server) DeletePortfolioGroupMembership(ctx context.Context, request api.DeletePortfolioGroupMembershipRequestObject) (api.DeletePortfolioGroupMembershipResponseObject, error) { + // TODO(#12) Implement Authorization + pgID := pacta.PortfolioGroupID(request.Body.PortfolioGroupId) + pID := pacta.PortfolioID(request.Body.PortfolioId) + err := s.DB.DeletePortfolioGroupMembership(s.DB.NoTxn(ctx), pgID, pID) + if err != nil { + return nil, oapierr.Internal("failed to delete portfolio group membership", zap.String("portfolio_group_id", string(pgID)), zap.String("portfolio_id", string(pID)), zap.Error(err)) + } + return api.DeletePortfolioGroupMembership204Response{}, nil +} + +// creates a portfolio group membership +// (PUT /portfolio-group-membership) +func (s *Server) CreatePortfolioGroupMembership(ctx context.Context, request api.CreatePortfolioGroupMembershipRequestObject) (api.CreatePortfolioGroupMembershipResponseObject, error) { + // TODO(#12) Implement Authorization + pgID := pacta.PortfolioGroupID(request.Body.PortfolioGroupId) + pID := pacta.PortfolioID(request.Body.PortfolioId) + err := s.DB.CreatePortfolioGroupMembership(s.DB.NoTxn(ctx), pgID, pID) + if err != nil { + return nil, oapierr.Internal("failed to create portfolio group membership", zap.String("portfolio_group_id", string(pgID)), zap.String("portfolio_id", string(pID)), zap.Error(err)) + } + return api.CreatePortfolioGroupMembership204Response{}, nil +} diff --git a/db/sqldb/portfolio.go b/db/sqldb/portfolio.go index bc3d881..104e5b6 100644 --- a/db/sqldb/portfolio.go +++ b/db/sqldb/portfolio.go @@ -3,6 +3,7 @@ package sqldb import ( "errors" "fmt" + "time" "github.com/RMI/pacta/db" "github.com/RMI/pacta/pacta" @@ -10,23 +11,26 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const portfolioSelectColumns = ` - portfolio.id, - portfolio.owner_id, - portfolio.name, - portfolio.description, - portfolio.created_at, - portfolio.holdings_date, - portfolio.blob_id, - portfolio.admin_debug_enabled, - portfolio.number_of_rows +const portfolioQueryStanza = ` + SELECT + portfolio.id, + portfolio.owner_id, + portfolio.name, + portfolio.description, + portfolio.created_at, + portfolio.holdings_date, + portfolio.blob_id, + portfolio.admin_debug_enabled, + portfolio.number_of_rows, + portfolio_group_membership.portfolio_group_id, + portfolio_group_membership.created_at + FROM portfolio + LEFT JOIN portfolio_group_membership + ON portfolio_group_membership.portfolio_id = portfolio.id ` func (d *DB) Portfolio(tx db.Tx, id pacta.PortfolioID) (*pacta.Portfolio, error) { - rows, err := d.query(tx, ` - SELECT `+portfolioSelectColumns+` - FROM portfolio - WHERE id = $1;`, id) + rows, err := d.query(tx, portfolioQueryStanza+` WHERE id = $1;`, id) if err != nil { return nil, fmt.Errorf("querying portfolio: %w", err) } @@ -34,14 +38,15 @@ func (d *DB) Portfolio(tx db.Tx, id pacta.PortfolioID) (*pacta.Portfolio, error) if err != nil { return nil, fmt.Errorf("translating rows to portfolios: %w", err) } - return exactlyOne("portfolio", id, pvs) + return exactlyOne("portfolio", id, valuesFromMap(pvs)) } func (d *DB) Portfolios(tx db.Tx, ids []pacta.PortfolioID) (map[pacta.PortfolioID]*pacta.Portfolio, error) { + if len(ids) == 0 { + return make(map[pacta.PortfolioID]*pacta.Portfolio), nil + } ids = dedupeIDs(ids) - rows, err := d.query(tx, ` - SELECT `+portfolioSelectColumns+` - FROM portfolio + rows, err := d.query(tx, portfolioQueryStanza+` WHERE id IN `+createWhereInFmt(len(ids))+`;`, idsToInterface(ids)...) if err != nil { return nil, fmt.Errorf("querying portfolios: %w", err) @@ -58,9 +63,7 @@ func (d *DB) Portfolios(tx db.Tx, ids []pacta.PortfolioID) (map[pacta.PortfolioI } func (d *DB) PortfoliosByOwner(tx db.Tx, ownerID pacta.OwnerID) ([]*pacta.Portfolio, error) { - rows, err := d.query(tx, ` - SELECT `+portfolioSelectColumns+` - FROM portfolio + rows, err := d.query(tx, portfolioQueryStanza+` WHERE owner_id = $1;`, ownerID) if err != nil { return nil, fmt.Errorf("querying portfolios: %w", err) @@ -69,7 +72,9 @@ func (d *DB) PortfoliosByOwner(tx db.Tx, ownerID pacta.OwnerID) ([]*pacta.Portfo if err != nil { return nil, fmt.Errorf("translating rows to portfolios: %w", err) } - return pvs, nil + // Note the map interface here is ~required in the deserialization process to track multiple memberships, + // so we're not just converting to a map and back. + return valuesFromMap(pvs), nil } func (d *DB) CreatePortfolio(tx db.Tx, p *pacta.Portfolio) (pacta.PortfolioID, error) { @@ -153,32 +158,65 @@ func (d *DB) DeletePortfolio(tx db.Tx, id pacta.PortfolioID) ([]pacta.BlobURI, e return buris, nil } -func rowToPortfolio(row rowScanner) (*pacta.Portfolio, error) { - p := &pacta.Portfolio{Owner: &pacta.Owner{}, Blob: &pacta.Blob{}} +type portfolioRow struct { + Portfolio *pacta.Portfolio + PortfolioGroupMembershipGroupID pacta.PortfolioGroupID + PortfolioGroupMembershipCreatedAt time.Time +} + +func rowToPortfolioRow(row rowScanner) (*portfolioRow, error) { + p := &portfolioRow{Portfolio: &pacta.Portfolio{Owner: &pacta.Owner{}, Blob: &pacta.Blob{}}} hd := pgtype.Timestamptz{} + mid := pgtype.Text{} + mca := pgtype.Timestamptz{} err := row.Scan( - &p.ID, - &p.Owner.ID, - &p.Name, - &p.Description, - &p.CreatedAt, + &p.Portfolio.ID, + &p.Portfolio.Owner.ID, + &p.Portfolio.Name, + &p.Portfolio.Description, + &p.Portfolio.CreatedAt, &hd, - &p.Blob.ID, - &p.AdminDebugEnabled, - &p.NumberOfRows, + &p.Portfolio.Blob.ID, + &p.Portfolio.AdminDebugEnabled, + &p.Portfolio.NumberOfRows, + &mid, + &mca, ) if err != nil { - return nil, fmt.Errorf("scanning into portfolio: %w", err) + return nil, fmt.Errorf("scanning into portfolio row: %w", err) } - p.HoldingsDate, err = decodeHoldingsDate(hd) + p.Portfolio.HoldingsDate, err = decodeHoldingsDate(hd) if err != nil { return nil, fmt.Errorf("decoding holdings date: %w", err) } + if mid.Valid { + p.PortfolioGroupMembershipGroupID = pacta.PortfolioGroupID(mid.String) + p.PortfolioGroupMembershipCreatedAt = mca.Time + } return p, nil } -func rowsToPortfolios(rows pgx.Rows) ([]*pacta.Portfolio, error) { - return mapRows("portfolio", rows, rowToPortfolio) +func rowsToPortfolios(rows pgx.Rows) (map[pacta.PortfolioID]*pacta.Portfolio, error) { + prows, err := mapRows("portfolio", rows, rowToPortfolioRow) + if err != nil { + return nil, fmt.Errorf("translating rows to portfolios: %w", err) + } + result := make(map[pacta.PortfolioID]*pacta.Portfolio) + for _, row := range prows { + id := row.Portfolio.ID + if _, ok := result[id]; !ok { + result[id] = row.Portfolio + } + if row.PortfolioGroupMembershipGroupID != "" { + result[id].MemberOf = append(result[id].MemberOf, &pacta.PortfolioGroupMembership{ + PortfolioGroup: &pacta.PortfolioGroup{ + ID: row.PortfolioGroupMembershipGroupID, + }, + CreatedAt: row.PortfolioGroupMembershipCreatedAt, + }) + } + } + return result, nil } func (db *DB) putPortfolio(tx db.Tx, p *pacta.Portfolio) error { diff --git a/db/sqldb/portfolio_group.go b/db/sqldb/portfolio_group.go index 0fa9e9d..8236ba1 100644 --- a/db/sqldb/portfolio_group.go +++ b/db/sqldb/portfolio_group.go @@ -19,20 +19,26 @@ func (d *DB) PortfolioGroup(tx db.Tx, id pacta.PortfolioGroupID) (*pacta.Portfol return exactlyOneFromMap("portfolioGroup", id, pgs) } +const portfolioGroupQueryStanza = ` + SELECT + portfolio_group.id, + portfolio_group.owner_id, + portfolio_group.name, + portfolio_group.description, + portfolio_group.created_at, + portfolio_group_membership.portfolio_id, + portfolio_group_membership.created_at + FROM portfolio_group + LEFT JOIN portfolio_group_membership + ON portfolio_group_membership.portfolio_group_id = portfolio_group.id +` + func (d *DB) PortfolioGroups(tx db.Tx, ids []pacta.PortfolioGroupID) (map[pacta.PortfolioGroupID]*pacta.PortfolioGroup, error) { + if len(ids) == 0 { + return make(map[pacta.PortfolioGroupID]*pacta.PortfolioGroup), nil + } ids = dedupeIDs(ids) - rows, err := d.query(tx, ` - SELECT - portfolio_group.id, - portfolio_group.owner_id, - portfolio_group.name, - portfolio_group.description, - portfolio_group.created_at, - portfolio_group_membership.portfolio_id, - portfolio_group_membership.created_at - FROM portfolio_group - LEFT JOIN portfolio_group_membership - ON portfolio_group_membership.portfolio_group_id = portfolio_group.id + rows, err := d.query(tx, portfolioGroupQueryStanza+` WHERE portfolio_group.id IN `+createWhereInFmt(len(ids))+`;`, idsToInterface(ids)...) if err != nil { return nil, fmt.Errorf("querying portfolio_groups: %w", err) @@ -40,6 +46,20 @@ func (d *DB) PortfolioGroups(tx db.Tx, ids []pacta.PortfolioGroupID) (map[pacta. return rowsToPortfolioGroups(rows) } +func (d *DB) PortfolioGroupsByOwner(tx db.Tx, ownerID pacta.OwnerID) ([]*pacta.PortfolioGroup, error) { + rows, err := d.query(tx, portfolioGroupQueryStanza+` WHERE portfolio_group.owner_id = $1;`, ownerID) + if err != nil { + return nil, fmt.Errorf("querying portfolio_groups: %w", err) + } + // Note the map interface here is ~required in the deserialization process to track multiple memberships, + // so we're not just converting to a map and back. + asMap, err := rowsToPortfolioGroups(rows) + if err != nil { + return nil, fmt.Errorf("converting rows to portfolio groups: %w", err) + } + return valuesFromMap(asMap), nil +} + func (d *DB) CreatePortfolioGroup(tx db.Tx, p *pacta.PortfolioGroup) (pacta.PortfolioGroupID, error) { if err := validatePortfolioGroupForCreation(p); err != nil { return "", fmt.Errorf("validating portfolio_group for creation: %w", err) @@ -96,25 +116,21 @@ func (d *DB) DeletePortfolioGroup(tx db.Tx, id pacta.PortfolioGroupID) error { } type portfolioGroupRow struct { - ID pacta.PortfolioGroupID - Owner *pacta.Owner - Name string - Description string - CreatedAt time.Time - PortfolioMemberID pacta.PortfolioID - PortfolioMemberCreatedAt time.Time + PortfolioGroup *pacta.PortfolioGroup + PortfolioGroupMembershipPortfolioID pacta.PortfolioID + PortfolioGroupMembershipCreatedAt time.Time } func rowToPortfolioGroupRow(row rowScanner) (*portfolioGroupRow, error) { - p := &portfolioGroupRow{Owner: &pacta.Owner{}} + p := &portfolioGroupRow{PortfolioGroup: &pacta.PortfolioGroup{Owner: &pacta.Owner{}}} mi := pgtype.Text{} ca := pgtype.Timestamptz{} err := row.Scan( - &p.ID, - &p.Owner.ID, - &p.Name, - &p.Description, - &p.CreatedAt, + &p.PortfolioGroup.ID, + &p.PortfolioGroup.Owner.ID, + &p.PortfolioGroup.Name, + &p.PortfolioGroup.Description, + &p.PortfolioGroup.CreatedAt, &mi, &ca, ) @@ -122,34 +138,35 @@ func rowToPortfolioGroupRow(row rowScanner) (*portfolioGroupRow, error) { return nil, fmt.Errorf("scanning into portfolio_group row: %w", err) } if mi.Valid { - p.PortfolioMemberID = pacta.PortfolioID(mi.String) - p.PortfolioMemberCreatedAt = ca.Time + p.PortfolioGroupMembershipPortfolioID = pacta.PortfolioID(mi.String) + p.PortfolioGroupMembershipCreatedAt = ca.Time } return p, nil } func rowsToPortfolioGroups(rows pgx.Rows) (map[pacta.PortfolioGroupID]*pacta.PortfolioGroup, error) { - pgrs, err := mapRows("portfolioGroup", rows, rowToPortfolioGroupRow) + pgRows, err := mapRows("portfolioGroup", rows, rowToPortfolioGroupRow) if err != nil { return nil, fmt.Errorf("translating rows to portfolio_groups: %w", err) } result := make(map[pacta.PortfolioGroupID]*pacta.PortfolioGroup) - for _, pgr := range pgrs { - if _, ok := result[pgr.ID]; !ok { - result[pgr.ID] = &pacta.PortfolioGroup{ - ID: pgr.ID, - Owner: pgr.Owner, - Name: pgr.Name, - Description: pgr.Description, - CreatedAt: pgr.CreatedAt, + for _, row := range pgRows { + id := row.PortfolioGroup.ID + if _, ok := result[id]; !ok { + result[id] = &pacta.PortfolioGroup{ + ID: row.PortfolioGroup.ID, + Owner: row.PortfolioGroup.Owner, + Name: row.PortfolioGroup.Name, + Description: row.PortfolioGroup.Description, + CreatedAt: row.PortfolioGroup.CreatedAt, } } - if pgr.PortfolioMemberID != "" { - result[pgr.ID].Members = append(result[pgr.ID].Members, &pacta.PortfolioGroupMembership{ + if row.PortfolioGroupMembershipPortfolioID != "" { + result[id].Members = append(result[id].Members, &pacta.PortfolioGroupMembership{ Portfolio: &pacta.Portfolio{ - ID: pgr.PortfolioMemberID, + ID: row.PortfolioGroupMembershipPortfolioID, }, - CreatedAt: pgr.PortfolioMemberCreatedAt, + CreatedAt: row.PortfolioGroupMembershipCreatedAt, }) } } diff --git a/db/sqldb/portfolio_group_test.go b/db/sqldb/portfolio_group_test.go index 7a650c0..3610d66 100644 --- a/db/sqldb/portfolio_group_test.go +++ b/db/sqldb/portfolio_group_test.go @@ -19,6 +19,8 @@ func TestPortfolioGroupCRUD(t *testing.T) { u2 := userForTestingWithKey(t, tdb, "2") o1 := ownerUserForTesting(t, tdb, u1) o2 := ownerUserForTesting(t, tdb, u2) + p1 := portfolioForTestingWithKey(t, tdb, "3") + p2 := portfolioForTestingWithKey(t, tdb, "4") pg1 := &pacta.PortfolioGroup{ Name: "portfolio-group-name", @@ -70,6 +72,51 @@ func TestPortfolioGroupCRUD(t *testing.T) { t.Fatalf("portfolio group mismatch (-want +got):\n%s", diff) } + if err := tdb.CreatePortfolioGroupMembership(tx, pg1.ID, p1.ID); err != nil { + t.Fatalf("creating portfolio group membership: %v", err) + } + if err := tdb.CreatePortfolioGroupMembership(tx, pg1.ID, p2.ID); err != nil { + t.Fatalf("creating portfolio group membership: %v", err) + } + if err := tdb.CreatePortfolioGroupMembership(tx, pg2.ID, p1.ID); err != nil { + t.Fatalf("creating portfolio group membership: %v", err) + } + if err := tdb.CreatePortfolioGroupMembership(tx, pg2.ID, p2.ID); err != nil { + t.Fatalf("creating portfolio group membership: %v", err) + } + if err := tdb.DeletePortfolioGroupMembership(tx, pg2.ID, p2.ID); err != nil { + t.Fatalf("deleting portfolio group membership: %v", err) + } + + actuals, err = tdb.PortfolioGroups(tx, []pacta.PortfolioGroupID{pg1.ID, pg2.ID}) + pg1.Members = []*pacta.PortfolioGroupMembership{{ + Portfolio: &pacta.Portfolio{ID: p1.ID}, + CreatedAt: time.Now(), + }, { + Portfolio: &pacta.Portfolio{ID: p2.ID}, + CreatedAt: time.Now(), + }} + pg2.Members = []*pacta.PortfolioGroupMembership{{ + Portfolio: &pacta.Portfolio{ID: p1.ID}, + CreatedAt: time.Now(), + }} + expecteds = map[pacta.PortfolioGroupID]*pacta.PortfolioGroup{ + pg1.ID: pg1, + pg2.ID: pg2, + } + if diff := cmp.Diff(expecteds, actuals, portfolioGroupCmpOpts()); diff != "" { + t.Fatalf("portfolio group mismatch (-want +got):\n%s", diff) + } + + expectedP1 := p1.Clone() + expectedP1.MemberOf = []*pacta.PortfolioGroupMembership{{ + PortfolioGroup: &pacta.PortfolioGroup{ID: pg1.ID}, + CreatedAt: time.Now(), + }, { + PortfolioGroup: &pacta.PortfolioGroup{ID: pg2.ID}, + CreatedAt: time.Now(), + }} + err = tdb.DeletePortfolioGroup(tx, pg1.ID) if err != nil { t.Fatalf("delete portfolio group: %v", err) @@ -174,8 +221,18 @@ func portfolioGroupCmpOpts() cmp.Option { portfolioGroupLessFn := func(a, b *pacta.PortfolioGroup) bool { return a.ID < b.ID } + portfolioMembershipLessFn := func(a, b *pacta.PortfolioGroupMembership) bool { + if a.Portfolio != nil && b.Portfolio != nil { + return a.Portfolio.ID < b.Portfolio.ID + } + if a.PortfolioGroup != nil && b.PortfolioGroup != nil { + return a.PortfolioGroup.ID < b.PortfolioGroup.ID + } + return false // Fundamentally uncomparable. + } return cmp.Options{ cmpopts.SortSlices(portfolioGroupLessFn), + cmpopts.SortSlices(portfolioMembershipLessFn), cmpopts.SortMaps(portfolioGroupIDLessFn), cmpopts.EquateEmpty(), cmpopts.EquateApproxTime(time.Second), diff --git a/db/sqldb/sqldb.go b/db/sqldb/sqldb.go index 6739dc5..32fcd2c 100644 --- a/db/sqldb/sqldb.go +++ b/db/sqldb/sqldb.go @@ -259,6 +259,14 @@ func exactlyOneFromMap[V any, K ~string](name string, id K, m map[K]V) (V, error return v, nil } +func valuesFromMap[V any, K ~string](m map[K]V) []V { + result := make([]V, 0, len(m)) + for _, v := range m { + result = append(result, v) + } + return result +} + func createWhereInFmt(n int) string { dollaz := make([]string, n) for i := 0; i < n; i++ { diff --git a/openapi/pacta.yaml b/openapi/pacta.yaml index 22d9aef..12c6a45 100644 --- a/openapi/pacta.yaml +++ b/openapi/pacta.yaml @@ -371,6 +371,118 @@ paths: responses: '204': description: the relationship changes were applied successfully + /portfolio-groups: + get: + summary: Returns the portfolio groups that the user has access to + operationId: listPortfolioGroups + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ListPortfolioGroupsResp' + post: + summary: Creates a portfolio group + description: Creates a new portfolio group + operationId: createPortfolioGroup + requestBody: + description: Initial portfolio group object properties + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioGroupCreate' + + responses: + '200': + description: portfolio group created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioGroup' + /portfolio-group/{id}: + get: + summary: Returns a portfolio group by ID + description: Returns a portfolio group based on a single ID + operationId: findPortfolioGroupById + parameters: + - name: id + in: path + description: ID of portfolio group to fetch + required: true + schema: + type: string + responses: + '200': + description: portfolio group response + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioGroup' + patch: + summary: Updates portfolio group properties + description: Updates a portfolio group's settable properties + operationId: updatePortfolioGroup + parameters: + - name: id + in: path + description: ID of the portfolio group to update + required: true + schema: + type: string + requestBody: + description: Portfolio Group object properties to update + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioGroupChanges' + responses: + '204': + description: the changes were applied successfully + delete: + summary: Deletes a portfolio group by ID + description: deletes a portfolio group based on the ID supplied - note this does not delete the portfolios that are members to this group + operationId: deletePortfolioGroup + parameters: + - name: id + in: path + description: ID of portfolio group to delete + required: true + schema: + type: string + responses: + '204': + description: portfolio group deleted + /portfolio-group-membership: + put: + summary: creates a portfolio group membership + description: creates a portfolio group membership + operationId: createPortfolioGroupMembership + requestBody: + description: Portfolio Group membership to create + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioGroupMembershipIds' + responses: + '204': + description: the changes were applied successfully + delete: + summary: Deletes a portfolio group membership + description: removes the membership of a portfolio in a portfolio - note this does not delete the portfolio or the portfolio group + operationId: deletePortfolioGroupMembership + requestBody: + description: Portfolio Group membership to delete + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioGroupMembershipIds' + responses: + '204': + description: portfolio group membership deleted /incomplete-uploads: get: description: Gets the incomplete uploads that the user is the owner of @@ -720,6 +832,89 @@ components: digest: type: string description: The hash (typically SHA256) that uniquely identifies this version of the PACTA model. + PortfolioGroupMembershipIds: + type: object + required: + - portfolioId + - portfolioGroupId + properties: + portfolioId: + type: string + description: the id of the portfolio member of the portfolio group + portfolioGroupId: + type: string + description: the id of the portfolio group + PortfolioGroupMembershipPortfolio: + type: object + required: + - portfolio + - createdAt + properties: + portfolio: + $ref: '#/components/schemas/Portfolio' + createdAt: + type: string + format: date-time + description: The time at which this membership was created. + PortfolioGroupMembershipPortfolioGroup: + type: object + required: + - portfolioGroup + - createdAt + properties: + portfolioGroup: + $ref: '#/components/schemas/PortfolioGroup' + createdAt: + type: string + format: date-time + description: The time at which this membership was created. + PortfolioGroupCreate: + type: object + required: + - name + - description + properties: + name: + type: string + description: the human meaningful name of the portfolio group + description: + type: string + description: an optional description of the contents or purpose of the portfolio group + PortfolioGroup: + type: object + required: + - id + - name + - description + - createdAt + properties: + id: + type: string + description: the system assigned id of the portfolio group + name: + type: string + description: the human meaningful name of the portfoio group + description: + type: string + description: the description of the contents or purpose of the portfolio group + members: + type: array + description: the list of portfolios that are members of this portfolio group + items: + $ref: '#/components/schemas/PortfolioGroupMembershipPortfolio' + createdAt: + type: string + format: date-time + description: The time at which this initiative was created. + PortfolioGroupChanges: + type: object + properties: + name: + type: string + description: the human meaningful name of the portfolio group + description: + type: string + description: the description of the contents or purpose of the portfolio group InitiativeCreate: type: object required: @@ -1128,6 +1323,11 @@ components: numberOfRows: type: integer description: The number of rows in the portfolio + groups: + type: array + description: The list of portfolio groups that this portfolio is a member of + items: + $ref: '#/components/schemas/PortfolioGroupMembershipPortfolioGroup' PortfolioChanges: type: object properties: @@ -1162,6 +1362,17 @@ components: type: array items: $ref: '#/components/schemas/Portfolio' + ListPortfolioGroupsReq: + type: object + ListPortfolioGroupsResp: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/PortfolioGroup' ParsePortfolioReq: type: object required: diff --git a/pacta/pacta.go b/pacta/pacta.go index 02e9409..8a4f223 100644 --- a/pacta/pacta.go +++ b/pacta/pacta.go @@ -356,6 +356,7 @@ type Portfolio struct { Blob *Blob AdminDebugEnabled bool NumberOfRows int + MemberOf []*PortfolioGroupMembership } func (o *Portfolio) Clone() *Portfolio { @@ -372,6 +373,7 @@ func (o *Portfolio) Clone() *Portfolio { Blob: o.Blob.Clone(), AdminDebugEnabled: o.AdminDebugEnabled, NumberOfRows: o.NumberOfRows, + MemberOf: cloneAll(o.MemberOf), } }