Files
upstream-corteza/server/system/reporting/reporting.go
upstream-mirror 7e385fc126
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:22:23 +05:00

742 lines
18 KiB
Go

// Package reporting implements utilities for working with system reports and
// DAL pipelines
//
// The package exists primarily for making the system service codebase smaller
// and prepare for reusability with compose record reports.
package reporting
import (
"context"
"fmt"
"github.com/cortezaproject/corteza/server/pkg/dal"
"github.com/cortezaproject/corteza/server/pkg/filter"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/spf13/cast"
)
type (
// run is a wrapper around the pipeline and corresponding
// output frame definitions
run struct {
Pipeline dal.Pipeline
Defs FrameDefinitionSet
}
modelFinder interface {
FindModel(dal.ModelRef) *dal.Model
}
// @todo not a fan of this anymore; consider reworking pkg/dal/service to
// provide a more sequential approach we can use.
dryRunner interface {
modelFinder
Dryrun(context.Context, dal.Pipeline) error
}
)
// Runs create a set of report runs based on step and frame definitions
func Runs(pr modelFinder, steps types.ReportStepSet, defs FrameDefinitionSet) (out []run, err error) {
// Prepare runs based on the provided definitions
//
// - If consecutive definitions point to the same source with the same name
// consider them to fall under the same workload (the link step)
// - else, one def per workload
auxDefs := make(FrameDefinitionSet, 0)
var aux run
for i, def := range defs {
if i == 0 {
auxDefs = append(auxDefs, def)
continue
}
// Definitions fall together
if def.Name == defs[i-1].Name && def.Source == defs[i-1].Source {
auxDefs = append(auxDefs, def)
continue
}
// Make run for the previous definition (exclude current!!)
aux, err = makeRun(pr, steps, auxDefs)
if err != nil {
return
}
out = append(out, aux)
// Prepare next definition batch including the current one
auxDefs = make(FrameDefinitionSet, 0)
auxDefs = append(auxDefs, def)
}
// Handle the ones (potentially) not covered by the above loop
if len(auxDefs) > 0 {
aux, err = makeRun(pr, steps, auxDefs)
if err != nil {
return
}
out = append(out, aux)
}
return
}
// Frames returns a set of Frame for the given workload & iterator combo
func Frames(ctx context.Context, iter dal.Iterator, r run) (ff []*Frame, err error) {
// Preprocessing on the workload's frame definitions; assure all
// columns/metdata are there to avoid nonesense later down the line
updateDefinitionColumns(r)
// @todo perhaps need to change; for now only this scenario introduces multiple
// frame defs per workload
if len(r.Defs) > 1 {
return stepLinkFrames(ctx, iter, r)
}
return stepFrames(ctx, iter, r)
}
// Describe returns a set of frame descriptions based on the given pipeline
func Describe(ctx context.Context, rr dryRunner, ss types.ReportStepSet, sources []string) (out []FrameDescription, err error) {
// Make a run for the whole thing
pp, err := makePipeline(rr, ss, nil)
if err != nil {
return
}
var aux []FrameDescription
for _, src := range sources {
// Use the requested source as root
sub := pp.Slice(src)
err = rr.Dryrun(ctx, sub)
if err != nil {
return
}
s := sub[0]
aux, err = describePipeline(s, src)
if err != nil {
return
}
out = append(out, aux...)
}
return
}
// stepLinkFrames is dedicated for the link step due to it's unique output
func stepLinkFrames(ctx context.Context, iter dal.Iterator, r run) (ff []*Frame, err error) {
defs := r.Defs
// @note this will only be called for the link step so it can freely panic if violated
defLink := r.Pipeline[0].(*dal.Link)
// Unpack frame definitions for the link
defLeft, defRight := unpackLinkDefs(defs, r.Pipeline)
// Init vars to keep track of the progress
// @note true is left, false is right
counters := make(map[bool]uint)
builders := make(map[bool]*reportFrameBuilder)
builders[true] = newReportFrameBuilder(defLeft)
builders[false] = newReportFrameBuilder(defRight)
builders[false].linked(defLink.On.Right, defLink.On.Left, defLink.RelLeft)
limits := make(map[bool]uint)
if defLeft.Paging != nil {
limits[true] = defLeft.Paging.Limit
}
if defRight.Paging != nil {
limits[false] = defRight.Paging.Limit
}
// Helper to determine if we need a next cursor
nextCursor := false
// Helpers for reading iterators
var (
ref string
row = &dal.Row{}
doingF = false
)
for iter.Next(ctx) {
if limits[true] > 0 && counters[true] >= limits[true] {
nextCursor = true
break
}
row.Reset()
_ = iter.Scan(row)
// Determine ref and which vars to use
aux, _ := row.GetValue(dal.LinkRefIdent, 0)
ref = cast.ToString(aux)
if ref == "" {
ref = defLeft.Ref
}
left := ref == defLeft.Ref
// When needed, flush the finished frames to the output
if left && doingF {
ff = append(ff, builders[false].done())
doingF = false
} else if !left {
doingF = true
}
builders[left].addRow(row)
counters[left]++
}
if err = iter.Err(); err != nil {
return
}
// If the loop ended before the limit cut it off, we need to finish the
// last right frame as it wasn't yet in the above loop
if !nextCursor {
if doingF {
ff = append(ff, builders[false].done())
}
}
// Apply paging cursor to the left frame
// @todo consider applying them to the right as well, for now, no
if nextCursor {
if builders[true].frame.Paging == nil {
builders[true].frame.Paging = &filter.Paging{}
}
builders[true].frame.Paging.NextPage, err = iter.ForwardCursor(row)
if err != nil {
return
}
}
// Complete the output with the left frame
// @note the left frame goes to the start and the right frames are in the same order
// as the related rows from the left frame.
return append([]*Frame{builders[true].done()}, ff...), nil
}
// unpackLinkDefs returns the left and the right frame definition disregarding
// the order of the definitions in the input
func unpackLinkDefs(defs FrameDefinitionSet, pp dal.Pipeline) (left, right *FrameDefinition) {
r := pp[0]
l := r.(*dal.Link)
find := func(defs FrameDefinitionSet, ref string) *FrameDefinition {
for _, def := range defs {
if def.Ref == ref {
return def
}
}
return nil
}
return find(defs, l.RelLeft), find(defs, l.RelRight)
}
// stepFrames is a generic iter to Frame converter
func stepFrames(ctx context.Context, iter dal.Iterator, r run) (ff []*Frame, err error) {
defs := r.Defs
// @note only the link step takes multiple defs and that one is not covered
// by this function
if len(defs) != 1 {
panic(fmt.Sprintf("impossible state: expecting one frame definition, got %d", len(defs)))
}
def := defs[0]
// Init vars to keep track of the progress
limit := uint(0)
counter := uint(0)
builder := newReportFrameBuilder(def)
if def.Paging != nil {
limit = def.Paging.Limit
}
// Helper to determine if we need a next cursor
nextCursor := false
// Helpers for reading iterators
row := &dal.Row{}
for iter.Next(ctx) {
if limit > 0 && counter >= limit {
nextCursor = true
break
}
row.Reset()
_ = iter.Scan(row)
builder.addRow(row)
counter++
}
if err = iter.Err(); err != nil {
return
}
// Apply paging cursor to the frame
if nextCursor {
if builder.frame.Paging == nil {
builder.frame.Paging = &filter.Paging{}
}
builder.frame.Paging.NextPage, err = iter.ForwardCursor(row)
if err != nil {
return
}
}
return append(ff, builder.done()), nil
}
func describePipeline(s dal.PipelineStep, src string) (out []FrameDescription, err error) {
aa := s.Attributes()
out = make([]FrameDescription, len(aa))
for i, a := range aa {
out[i].Source = src
out[i].Columns = mappingToFrameCols(a)
}
// @note this case is only possible for the link step; expand when/if needed
if len(out) == 2 {
l := s.(*dal.Link)
out[0].Ref = l.RelLeft
out[1].Ref = l.RelRight
}
return
}
func makeRun(pr modelFinder, ss types.ReportStepSet, defs FrameDefinitionSet) (out run, err error) {
var pp dal.Pipeline
pp, err = makePipeline(pr, ss, defs)
if err != nil {
return
}
out.Defs = defs
out.Pipeline = pp.Slice(defs[0].Source)
return
}
// makePipeline converts the given report steps into the DAL pipeline
func makePipeline(mf modelFinder, ss types.ReportStepSet, defs FrameDefinitionSet) (pp dal.Pipeline, err error) {
for _, step := range ss {
switch {
case step.Load != nil:
aux, err := convStepLoad(mf, *step.Load, defs.FilterBySource(step.Load.Name))
if err != nil {
return nil, err
}
pp = append(pp, aux)
case step.Aggregate != nil:
aux, err := convStepAggregate(*step.Aggregate, defs.FilterBySource(step.Aggregate.Name))
if err != nil {
return nil, err
}
pp = append(pp, aux)
case step.Join != nil:
aux, err := convStepJoin(*step.Join, defs.FilterBySource(step.Join.Name))
if err != nil {
return nil, err
}
pp = append(pp, aux)
case step.Link != nil:
aux, err := convStepLink(*step.Link, defs.FilterBySource(step.Link.Name))
if err != nil {
return nil, err
}
pp = append(pp, aux)
default:
// this should never happen
panic(fmt.Errorf("unknown step type: %v", step.Kind))
}
}
return pp, pp.LinkSteps()
}
// mappingToFrameCols converts pipeline AttributeMapping to FrameColumnSet
func mappingToFrameCols(mm []dal.AttributeMapping) FrameColumnSet {
out := make(FrameColumnSet, 0, len(mm))
for _, m := range mm {
out = append(out, mappingToFrameCol(m))
}
return out
}
// @note current implementation a bit _rushed_ since I'll probably rethink
// how the pipeline handles attributes -- will revisit then.
func mappingToFrameCol(m dal.AttributeMapping) FrameColumn {
p := m.Properties()
const (
// Coppied around to reduce imports
emailLength = 254
urlLength = 2048
attachmentResType = "corteza::system:attachment"
userResType = "corteza::system:user"
moduleResType = "corteza::compose:module"
)
l := m.Properties().Label
if l == "" {
l = m.Identifier()
}
out := FrameColumn{
Name: m.Identifier(),
Label: l,
Kind: "String",
Primary: p.IsPrimary,
System: p.IsSystem,
Multivalue: p.IsMultivalue,
MultivalueDelimiter: p.MultivalueDelimiter,
}
if out.MultivalueDelimiter == "" {
out.MultivalueDelimiter = "\n"
}
switch t := p.Type.(type) {
case *dal.TypeBoolean:
out.Kind = "Boolean"
case *dal.TypeDate, *dal.TypeTime, *dal.TypeTimestamp:
out.Kind = "DateTime"
case *dal.TypeNumber:
out.Kind = "Number"
case *dal.TypeEnum:
out.Kind = "Select"
case *dal.TypeText:
// @note temporary solution; we should push some meta along with it
if t.Length == emailLength {
out.Kind = "Email"
} else if t.Length == urlLength {
out.Kind = "URL"
} else {
out.Kind = "String"
}
case *dal.TypeRef:
switch t.RefModel.ResourceType {
case moduleResType:
out.Kind = "Record"
case userResType:
out.Kind = "User"
case attachmentResType:
out.Kind = "File"
}
}
return out
}
// Report step -> DAL step conversion
// convStepLoad converts ReportStepLoad to dal.Datasource
func convStepLoad(pr modelFinder, step types.ReportStepLoad, defs FrameDefinitionSet) (out *dal.Datasource, err error) {
// Validation
if len(defs) > 1 {
err = fmt.Errorf("cannot convert load step: expecting at most one definition, got %d", len(defs))
return
}
// Get additional filtering
var extf filter.Filter
if len(defs) == 1 {
extf = filterFromDef(defs[0])
}
// Prepare model ref
mfr, err := makeModelRef(step)
if err != nil {
return
}
// @todo refactor this out after we support other resources with potentially missing soft delete fields
f, err := dal.FilterFromExpr(step.Filter.Node()).
MergeFilters(filter.Generic(filter.WithStateConstraint("deletedAt", filter.StateExcluded)))
if err != nil {
return
}
f, err = f.MergeFilters(extf)
if err != nil {
return
}
// Make pipeline step
return &dal.Datasource{
Ident: step.Name,
Filter: f,
ModelRef: mfr,
OutAttributes: modelAttributes(pr, step, mfr),
}, nil
}
// convStepAggregate converts ReportStepAggregate to dal.Aggregate
func convStepAggregate(step types.ReportStepAggregate, defs FrameDefinitionSet) (out *dal.Aggregate, err error) {
// Validation
if len(defs) > 1 {
err = fmt.Errorf("cannot convert aggregate step: expecting at most one definition, got %d", len(defs))
return
}
// Get additional filtering
var extf filter.Filter
if len(defs) == 1 {
extf = filterFromDef(defs[0])
}
f, err := dal.FilterFromExpr(step.Filter.Node()).
MergeFilters(extf)
if err != nil {
return
}
ggs := make([]dal.AggregateAttr, 0, len(step.Keys))
for _, c := range step.Keys {
ggs = append(ggs, dal.AggregateAttr{
Key: true,
Identifier: c.Name,
Label: c.Label,
Expression: c.Def.Node(),
})
}
vvs := make([]dal.AggregateAttr, 0, len(step.Columns))
for _, c := range step.Columns {
vvs = append(vvs, dal.AggregateAttr{
Identifier: c.Name,
Label: c.Label,
Expression: c.Def.Node(),
})
}
// Make pipeline step
out = &dal.Aggregate{
Ident: step.Name,
RelSource: step.Source,
Filter: f,
Group: ggs,
OutAttributes: vvs,
}
return
}
// convStepJoin converts ReportStepJoin to dal.Join
func convStepJoin(step types.ReportStepJoin, defs FrameDefinitionSet) (out *dal.Join, err error) {
// Validation
if len(defs) > 1 {
err = fmt.Errorf("cannot convert join step: expecting at most one definition, got %d", len(defs))
return
}
// Get additional filtering
var extf filter.Filter
if len(defs) == 1 {
extf = filterFromDef(defs[0])
}
f, err := dal.FilterFromExpr(step.Filter.Node()).
MergeFilters(extf)
if err != nil {
return
}
// Make pipeline step
out = &dal.Join{
Ident: step.Name,
RelLeft: step.LocalSource,
RelRight: step.ForeignSource,
Filter: f,
On: dal.JoinPredicate{
Left: step.LocalColumn,
Right: step.ForeignColumn,
},
}
return
}
// convStepLink converts ReportStepLink to dal.Link
func convStepLink(step types.ReportStepLink, defs FrameDefinitionSet) (out *dal.Link, err error) {
// Validation
if len(defs) > 2 {
err = fmt.Errorf("cannot convert join step: expecting at most two definitions, got %d", len(defs))
return
}
// @todo additional filtering; will need to split the dal.Link filter into
// left and right for more control and clarity
var extf filter.Filter
if len(defs) > 0 {
extf = filterFromDef(defs[0])
}
// Get additional filtering
f, err := dal.FilterFromExpr(step.Filter.Node()).
MergeFilters(extf)
if err != nil {
return
}
// @todo temporary constraint
if len(defs) == 2 {
d := defs[1]
if d.Filter != nil && d.Filter.Node() != nil || len(d.Sort) > 0 {
return nil, fmt.Errorf("temporary constraint: cannot apply sorting/filtering to foreign frame definition")
}
}
// Make pipeline step
out = &dal.Link{
Ident: step.Name,
RelLeft: step.LocalSource,
RelRight: step.ForeignSource,
On: dal.LinkPredicate{
Left: step.LocalColumn,
Right: step.ForeignColumn,
},
Filter: f,
}
return
}
// updateDefinitionColumns assures run's frame column completeness
func updateDefinitionColumns(r run) {
ppAttrs := r.Pipeline[0].Attributes()
for i, def := range r.Defs {
if len(def.Columns) > 0 {
continue
}
def.Columns = mappingToFrameCols(ppAttrs[i])
}
}
// makeModelRef returns the model ref based on the step load definition
// @todo should be expanded when we support models that are not compose modules
func makeModelRef(step types.ReportStepLoad) (out dal.ModelRef, err error) {
var (
connectionID uint64
moduleID, namespaceID uint64
module, namespace string
aux any
ok bool
)
if aux, ok = step.Definition["moduleID"]; ok {
moduleID = cast.ToUint64(aux)
} else if aux, ok = step.Definition["module"]; ok {
module = cast.ToString(aux)
} else {
err = fmt.Errorf("step definition is missing moduleID or module")
return
}
if aux, ok = step.Definition["namespaceID"]; ok {
namespaceID = cast.ToUint64(aux)
} else if aux, ok = step.Definition["namespace"]; ok {
namespace = cast.ToString(aux)
} else {
err = fmt.Errorf("step definition is missing namespaceID or namespace")
return
}
// Connection is optional, default is primary connection
if aux, ok = step.Definition["connectionID"]; ok {
connectionID = cast.ToUint64(aux)
}
out.ConnectionID = connectionID
out.Refs = make(map[string]any)
// Use only one of the two identifier variations with priority to ID
if moduleID > 0 {
out.Refs["moduleID"] = moduleID
} else {
out.Refs["module"] = module
}
if namespaceID > 0 {
out.Refs["namespaceID"] = namespaceID
} else {
out.Refs["namespace"] = namespace
}
return
}
// modelAttributes returns the attributes defined on the referenced model
func modelAttributes(pr modelFinder, step types.ReportStepLoad, mfr dal.ModelRef) []dal.AttributeMapping {
// All of the attributes
attrs, err := getModelAttrs(pr, mfr)
if err != nil {
return nil
}
return attrToMapping(attrs...)
}
func getModelAttrs(pr modelFinder, mfr dal.ModelRef) (attrs dal.AttributeSet, err error) {
m := pr.FindModel(mfr)
if m == nil {
return nil, fmt.Errorf("model not found: %v", mfr)
}
return m.Attributes, nil
}
func attrToMapping(aa ...*dal.Attribute) (out []dal.AttributeMapping) {
for _, a := range aa {
out = append(out, dal.SimpleAttr{
Ident: a.Ident,
Src: a.Ident,
Props: dal.MapProperties{
Label: a.Label,
Type: a.Type,
IsSystem: a.System,
Nullable: a.Type.IsNullable(),
IsPrimary: a.PrimaryKey,
// @todo add multi value delimiter; it's currently not set on the
// attribute so we might need to rethink this a bit
IsMultivalue: a.MultiValue,
},
})
}
return
}
func filterFromDef(def *FrameDefinition) (out filter.Filter) {
aux := filter.Generic(
filter.WithExpressionParsed(def.Filter.Node()),
filter.WithOrderBy(def.Sort),
)
if def.Paging != nil {
aux = aux.With(
filter.WithCursor(def.Paging.PageCursor),
filter.WithLimit(def.Paging.Limit),
)
}
return aux
}