snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

View File

@@ -0,0 +1,559 @@
package dal
import (
"context"
"fmt"
"sync"
"github.com/cortezaproject/corteza/server/pkg/id"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ddl"
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
"github.com/cortezaproject/corteza/server/pkg/dal"
"github.com/cortezaproject/corteza/server/pkg/filter"
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
"github.com/jmoiron/sqlx"
)
type (
// connection provides (pkg/dal.Connection) interface to RDBMS implementation
//
// In other words: this allows Corteza to read Records from the supported SQL databases
connection struct {
mux sync.RWMutex
models map[string]*model
driver dal.Driver
db sqlx.ExtContext
dialect drivers.Dialect
dataDefiner ddl.DataDefiner
}
)
var (
dalDriver dal.Driver
)
func init() {
dalDriver = dal.Driver{
Type: "corteza::dal:driver:rdbms",
Operations: dal.FullOperations(),
Connection: dal.NewDSNDriverConnectionConfig(),
}
dal.RegisterDriver(dalDriver)
}
func Connection(db sqlx.ExtContext, dialect drivers.Dialect, dd ddl.DataDefiner) *connection {
return &connection{
db: db,
dialect: dialect,
dataDefiner: dd,
models: make(map[string]*model),
driver: dalDriver,
}
}
// model returns rdbms/dal model (converted dal.Model)
//
// It constructs key from res-type + res + ident
// and caches it in the connection
//
// This allows us to have same resource or ident on different res-types
// For example: module's model for revisions has same resouce and ident but different type
func (c *connection) withModel(m *dal.Model, fn func(m *model) error) error {
var (
key = cacheKey(m)
)
c.mux.RLock()
defer c.mux.RUnlock()
if cached, ok := c.models[key]; ok {
return fn(cached)
}
return fmt.Errorf("model %q (%d) not loaded", key, m.ResourceID)
}
func (c *connection) Operations() dal.OperationSet {
return c.driver.Operations
}
func (c *connection) Can(operations ...dal.Operation) bool {
return c.Operations().IsSuperset(operations...)
}
func (c *connection) Create(ctx context.Context, m *dal.Model, rr ...dal.ValueGetter) (err error) {
return c.withModel(m, func(m *model) error {
return m.Create(ctx, rr...)
})
}
func (c *connection) Update(ctx context.Context, m *dal.Model, r dal.ValueGetter) (err error) {
return c.withModel(m, func(m *model) error {
return m.Update(ctx, r)
})
}
func (c *connection) Lookup(ctx context.Context, m *dal.Model, pkv dal.ValueGetter, r dal.ValueSetter) (err error) {
return c.withModel(m, func(m *model) error {
return m.Lookup(ctx, pkv, r)
})
}
func (c *connection) Search(ctx context.Context, m *dal.Model, f filter.Filter) (i dal.Iterator, _ error) {
return i, c.withModel(m, func(m *model) (err error) {
i, err = m.Search(f)
return
})
}
func (c *connection) Count(ctx context.Context, m *dal.Model, f filter.Filter) (i uint, _ error) {
return i, c.withModel(m, func(m *model) (err error) {
i, err = m.Count(ctx, f)
return
})
}
func (c *connection) Analyze(ctx context.Context, m *dal.Model) (a map[string]dal.OpAnalysis, err error) {
// @todo somehow (probably operations) bring in the info what can be done
// for now, since we're quite rigid on the drivers, this will do.
//
// @note this is a temporary hack until we properly address the first point.
// No point in complicating it at this stage.
if c.db.DriverName() == "sqlserver" {
a = map[string]dal.OpAnalysis{}
} else {
a = map[string]dal.OpAnalysis{
dal.OpAnalysisAggregate: {
ScanCost: dal.CostCheep,
SearchCost: dal.CostCheep,
FilterCost: dal.CostCheep,
SortCost: dal.CostCheep,
},
}
}
return
}
func (c *connection) Aggregate(ctx context.Context, m *dal.Model, f filter.Filter, groupBy []dal.AggregateAttr, aggrExpr []dal.AggregateAttr, having *ql.ASTNode) (i dal.Iterator, _ error) {
return i, c.withModel(m, func(m *model) (err error) {
i, err = m.Aggregate(f, groupBy, aggrExpr, having)
return
})
}
func (c *connection) Delete(ctx context.Context, m *dal.Model, pkv dal.ValueGetter) (err error) {
return c.withModel(m, func(m *model) error {
return m.Delete(ctx, pkv)
})
}
func (c *connection) Truncate(ctx context.Context, m *dal.Model) (err error) {
return c.withModel(m, func(m *model) error {
return m.Truncate(ctx)
})
}
func (c *connection) Models(ctx context.Context) (dal.ModelSet, error) {
// not raising not-supported error
// because we do not want to break
// DAL service model adding procedure
return nil, nil
}
// CreateModel checks/creates db tables in the database and catches the processed model
//
// @todo DDL operations
func (c *connection) CreateModel(ctx context.Context, mm ...*dal.Model) (err error) {
for _, m := range mm {
if err = validate(m); err != nil {
return
}
}
c.mux.Lock()
defer c.mux.Unlock()
for _, m := range mm {
_, err = c.dataDefiner.TableLookup(ctx, m.Ident)
if errors.IsNotFound(err) {
if err = ddl.CreateModel(ctx, c.dataDefiner, m); err != nil {
return
}
} else if err != nil {
return
}
if err = ddl.EnsureIndexes(ctx, c.dataDefiner, m.Indexes...); err != nil {
return
}
// cache the model
c.models[cacheKey(m)] = Model(m, c.db, c.dialect)
}
return
}
// DeleteModel removes db tables from the database and removes the processed model from cache
//
// @todo DDL operations
// @todo some tables should not be removed (like compose_record on primary connection)
func (c *connection) DeleteModel(ctx context.Context, mm ...*dal.Model) (err error) {
c.mux.Lock()
defer c.mux.Unlock()
for _, m := range mm {
// @todo check if table exists and if it can be removed
if err = c.DeleteModel(ctx, m); err != nil {
return
}
// remove from cache
delete(c.models, cacheKey(m))
}
return
}
// UpdateModel alters db tables from the database and refreshes the processed model in the cache
//
// @todo DDL operations
// @todo some tables should not be removed (like compose_record on primary connection)
func (c *connection) UpdateModel(ctx context.Context, old *dal.Model, new *dal.Model) (err error) {
if err = validate(new); err != nil {
return
}
c.mux.Lock()
defer c.mux.Unlock()
// remove from cache
delete(c.models, cacheKey(old))
// @todo check if column exists and if it can be removed
// update the cache
c.models[cacheKey(new)] = Model(new, c.db, c.dialect)
return
}
// AssertSchemaAlterations returns a new set of Alterations based on what the underlying
// schema already provides -- it discards alterations for column additions that already exist, etc.
func (c *connection) AssertSchemaAlterations(ctx context.Context, model *dal.Model, aa ...*dal.Alteration) (out []*dal.Alteration, err error) {
var aux []*dal.Alteration
t, err := c.dataDefiner.TableLookup(ctx, model.Ident)
if err != nil && errors.IsNotFound(err) {
// Since there is no thing for this model we need to create it and all the
// alterations are pointless
return []*dal.Alteration{{
ID: id.Next(),
Resource: model.Resource,
ResourceType: model.ResourceType,
ConnectionID: model.ConnectionID,
ModelAdd: &dal.ModelAdd{
Model: model,
},
}}, nil
}
if err != nil {
return
}
// Index columns by ident for easier lookup
colIndex := make(map[string]*ddl.Column)
for _, c := range t.Columns {
colIndex[c.Ident] = c
}
for _, a := range aa {
switch {
case a.AttributeAdd != nil:
aux, err = c.assertAlterationAttributeAdd(t, colIndex, a)
if err != nil {
return
}
out = append(out, aux...)
case a.AttributeDelete != nil:
aux, err = c.assertAlterationAttributeDelete(t, colIndex, a)
if err != nil {
return
}
out = append(out, aux...)
case a.AttributeReType != nil:
aux, err = c.assertAlterationAttributeReType(t, colIndex, a)
if err != nil {
return
}
out = append(out, aux...)
case a.AttributeReEncode != nil:
aux, err = c.assertAlterationAttributeReEncode(t, colIndex, a)
if err != nil {
return
}
out = append(out, aux...)
case a.ModelAdd != nil:
aux, err = c.assertAlterationModelAdd(t, colIndex, a)
if err != nil {
return
}
out = append(out, aux...)
case a.ModelDelete != nil:
aux, err = c.assertAlterationModelDelete(t, colIndex, a)
if err != nil {
return
}
out = append(out, aux...)
}
}
return
}
// ApplyAhlteration applies the given alterations to the underlying schema
//
// The returned slice of error indicates what alterations failed.
// If the corresponding index is nil, the alteration was successful.
func (c *connection) ApplyAlteration(ctx context.Context, model *dal.Model, alt ...*dal.Alteration) (errs []error) {
var (
err error
failed = make(map[uint64]bool, len(alt)/2)
)
for _, a := range alt {
// Skip since the alteration we depend on failed
if a.DependsOn != 0 && failed[a.DependsOn] {
errs = append(errs, fmt.Errorf("skipping alteration %d: depending alteration %d failed", a.ID, a.DependsOn))
continue
}
switch {
case a.AttributeAdd != nil:
err = c.applyAlterationAttributeAdd(ctx, model, a)
case a.AttributeDelete != nil:
err = c.applyAlterationAttributeDelete(ctx, model, a)
case a.AttributeReType != nil:
err = c.applyAlterationAttributeReType(ctx, model, a)
case a.AttributeReEncode != nil:
err = c.applyAlterationAttributeReEncode(ctx, model, a)
case a.ModelAdd != nil:
err = c.applyAlterationModelAdd(ctx, model, a)
case a.ModelDelete != nil:
err = c.applyAlterationModelDelete(ctx, model, a)
}
if err != nil {
failed[a.ID] = true
}
errs = append(errs, err)
}
return
}
func (c *connection) applyAlterationAttributeAdd(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
col, err := c.dataDefiner.ConvertAttribute(alt.AttributeAdd.Attr)
if err != nil {
return
}
return c.dataDefiner.ColumnAdd(ctx, model.Ident, col)
}
func (c *connection) applyAlterationAttributeDelete(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
return c.dataDefiner.ColumnDrop(ctx, model.Ident, alt.AttributeDelete.Attr.StoreIdent())
}
func (c *connection) applyAlterationAttributeReType(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
col, err := c.dataDefiner.ConvertAttribute(alt.AttributeReType.Attr)
if err != nil {
return
}
return c.dataDefiner.ColumnReType(ctx, model.Ident, col.Ident, col.Type)
}
// @todo might consider droppig this one for now
func (c *connection) applyAlterationAttributeReEncode(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
// ...
return
}
func (c *connection) applyAlterationModelAdd(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
t, err := c.dataDefiner.ConvertModel(model)
if err != nil {
return
}
return c.dataDefiner.TableCreate(ctx, t)
}
func (c *connection) applyAlterationModelDelete(ctx context.Context, model *dal.Model, alt *dal.Alteration) (err error) {
return c.dataDefiner.TableDrop(ctx, model.Ident)
}
func cacheKey(m *dal.Model) (key string) {
key = m.ResourceType + "|" + m.Resource + "|" + m.Ident
if key == "" {
panic("can not add model without a key (combo of resource type, resource and ident)")
}
return
}
func (c *connection) assertAlterationAttributeAdd(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
if alt.AttributeAdd.Attr.Store.Type() == dal.AttributeCodecRecordValueSetJSON {
// RecordValue codec needs to be checked a bit differently since we're worried about the column that contains
// the JSON, not the attribute ident itself
return c.assertAlterationNestedAttributeAdd(table, colIndex, alt)
} else {
return c.assertAlterationStandaloneAttributeAdd(table, colIndex, alt)
}
}
func (c *connection) assertAlterationNestedAttributeAdd(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
col := colIndex[alt.AttributeAdd.Attr.StoreIdent()]
if col != nil {
return
}
a := &dal.Alteration{
ID: id.Next(),
BatchID: alt.BatchID,
DependsOn: alt.DependsOn,
Resource: alt.Resource,
ConnectionID: alt.ConnectionID,
AttributeAdd: &dal.AttributeAdd{
Attr: &dal.Attribute{
Ident: alt.AttributeAdd.Attr.StoreIdent(),
Store: &dal.CodecPlain{},
Type: &dal.TypeJSON{Nullable: false},
},
},
}
out = append(out, a)
// Update colIndex so other alterations won't duplicate
auxCol, err := c.dialect.AttributeToColumn(a.AttributeAdd.Attr)
if err != nil {
return nil, err
}
colIndex[auxCol.Ident] = auxCol
return out, nil
}
func (c *connection) assertAlterationStandaloneAttributeAdd(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
col := colIndex[alt.AttributeAdd.Attr.StoreIdent()]
if col == nil {
out = append(out, alt)
return
}
auxCol, err := c.dialect.AttributeToColumn(alt.AttributeAdd.Attr)
if err != nil {
return nil, err
}
if !c.dialect.ColumnFits(col, auxCol) {
out = append(out, &dal.Alteration{
ID: id.Next(),
BatchID: alt.BatchID,
DependsOn: alt.DependsOn,
Resource: alt.Resource,
ResourceType: alt.ResourceType,
ConnectionID: alt.ConnectionID,
AttributeReType: &dal.AttributeReType{
Attr: alt.AttributeAdd.Attr,
To: alt.AttributeAdd.Attr.Type,
},
})
}
// Update colIndex so other alterations won't duplicate
colIndex[auxCol.Ident] = auxCol
return out, nil
}
func (c *connection) assertAlterationAttributeDelete(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
col := colIndex[alt.AttributeDelete.Attr.StoreIdent()]
if col == nil {
return
}
delete(colIndex, alt.AttributeDelete.Attr.StoreIdent())
out = append(out, alt)
return
}
func (c *connection) assertAlterationAttributeReType(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
col := colIndex[alt.AttributeReType.Attr.StoreIdent()]
if col == nil {
err = fmt.Errorf("cannot alter %s, column does not exist", alt.AttributeReType.Attr.StoreIdent())
return
}
// Since it's a JSON we don't need to do anything
// @todo consider adding some migration logic here
if alt.AttributeReType.Attr.Store.Type() == dal.AttributeCodecRecordValueSetJSON {
return
}
auxCol, err := c.dialect.AttributeToColumn(alt.AttributeReType.Attr)
if err != nil {
return
}
if c.dialect.ColumnFits(auxCol, col) {
return
}
out = append(out, alt)
return
}
// @todo for now it just creates a new column; we should add some migration in the future
func (c *connection) assertAlterationAttributeReEncode(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
auxCodec := *alt.AttributeReEncode.Attr
auxCodec.Store = alt.AttributeReEncode.To
out = append(out, &dal.Alteration{
ID: alt.ID,
BatchID: alt.BatchID,
DependsOn: alt.DependsOn,
Resource: alt.Resource,
ResourceType: alt.ResourceType,
ConnectionID: alt.ConnectionID,
AttributeAdd: &dal.AttributeAdd{
Attr: &auxCodec,
},
})
return
}
func (c *connection) assertAlterationModelAdd(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
if table != nil {
return
}
out = append(out, alt)
return
}
func (c *connection) assertAlterationModelDelete(table *ddl.Table, colIndex map[string]*ddl.Column, alt *dal.Alteration) (out []*dal.Alteration, err error) {
if table == nil {
return
}
out = append(out, alt)
return
}

View File

@@ -0,0 +1,323 @@
package dal
import (
"context"
"database/sql"
"fmt"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/pkg/dal"
"github.com/cortezaproject/corteza/server/pkg/filter"
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
)
type (
iterator struct {
// source model; how data we are reading from is shaped
src *model
// destination model; how data we are reading into is shaped
// this is used to create scan buffer
// when not doing plain selection from one table final results might
// require a different list of columns and scanning needs to be adjusted
dst *model
// buffer for scanned rows
scanBuf []any
// results from the last read
rows *sql.Rows
// last error
err error
query *goqu.SelectDataset
sorting filter.SortExprSet
cursor *filter.PagingCursor
limit uint
// set this flag when iterating over aggregated results
// it will affect how
// - cursor conditions are applied (WHERE )
aggregated bool
}
)
func (i *iterator) Next(ctx context.Context) bool {
if i.err == nil && i.rows == nil {
i.rows, i.err = i.fetch(ctx)
}
if i.err != nil {
return false
}
return i.rows.Next()
}
// More fetches more records from the point of last record
func (i *iterator) More(max uint, last dal.ValueGetter) (err error) {
if i.rows != nil {
if err = i.rows.Close(); err != nil {
return fmt.Errorf("could not close previous query: %w", err)
}
i.rows = nil
}
i.limit = max
if last != nil {
if i.cursor == nil || !i.cursor.ROrder {
i.cursor, err = i.ForwardCursor(last)
} else {
i.cursor, err = i.BackCursor(last)
}
if err != nil {
return fmt.Errorf("could not collect cursor values: %w", err)
}
}
return nil
}
func (i *iterator) Preload(_ context.Context, max uint, cur *filter.PagingCursor) (err error) {
if i.rows != nil {
if err = i.rows.Close(); err != nil {
return fmt.Errorf("could not close previous query: %w", err)
}
i.rows = nil
}
i.limit = max
if cur != nil {
i.cursor = cur
}
return i.err
}
func (i *iterator) Sorting() filter.SortExprSet {
return i.sorting
}
func (i *iterator) fetch(ctx context.Context) (rows *sql.Rows, err error) {
if i.err != nil {
return nil, i.err
}
if i.query == nil {
return nil, fmt.Errorf("can not fetch without query")
}
if i.scanBuf == nil {
// we're going to init scan buffer only once
// and rely on the query.Rows.Scan function to
// fill it up with fresh values!
i.scanBuf = i.dst.table.MakeScanBuffer()
}
var (
cur = i.cursor
tmp exp.Expression
query string
args []any
// contains query with ORDER BY and WHERE clauses
sqlExpr = i.query
sort = i.sorting.Clone()
)
{
// Apply limit from the filter
sqlExpr = sqlExpr.Limit(i.limit)
if cur != nil {
// @todo this needs to work with embedded attributes (non physical columns) as well!
tmp, err = rdbms.CursorExpression(
cur,
func(ident string) (exp.Expression, error) { return i.src.table.AttributeExpression(ident) },
func(ident string, val any) (exp.Expression, error) {
attr := i.dst.model.Attributes.FindByIdent(ident)
if attr == nil {
panic("unknown attribute " + ident + " used in cursor expression cast callback")
}
enc, err := i.dst.dialect.TypeWrap(attr.Type).Encode(val)
if err != nil {
return nil, err
}
return exp.NewLiteralExpression("?", enc), nil
},
)
if err != nil {
return
}
if i.aggregated {
sqlExpr = sqlExpr.Having(tmp)
} else {
sqlExpr = sqlExpr.Where(tmp)
}
if cur.IsROrder() {
if i.limit > 0 {
// When paging with the reverse cursor AND limit set
// we need to do a do sub-query reverse sort to ensure
// that we only select the rows that make sense
innerSort := sort.Clone()
innerSort.Reverse()
// Wrap the fil & ordered sub-query with cursor-conditions
sqlExpr = i.src.dialect.GOQU().From(sqlExpr.Order(i.orderByExp(innerSort)...).As(i.src.model.Ident))
// make sure we don't reverse it again
} else {
// if limit is not set it does not make sense to wrap the select
// and resort it, so let's reverse the main sorting
sort.Reverse()
}
}
}
}
{
// Apply sort
// @todo is this going to be a problem? do we need to properly address the columns
// from the sub-query?
if len(sort) > 0 {
sqlExpr = sqlExpr.Order(i.orderByExp(sort)...)
}
}
if query, args, err = sqlExpr.ToSQL(); err != nil {
return nil, err
}
rows, err = i.src.conn.QueryContext(ctx, query, args...)
if errors.Is(err, sql.ErrNoRows) {
// no rows, no error
return nil, nil
}
return
}
// generates slice of ordered-expressions
func (i *iterator) orderByExp(sort filter.SortExprSet) (oe []exp.OrderedExpression) {
for _, s := range sort {
// assuming all columns were pre-validated!
tmp, _ := i.src.table.AttributeExpression(s.Column)
if s.Descending {
oe = append(oe, i.src.dialect.OrderedExpression(tmp, exp.DescSortDir, exp.NullsLastSortType))
} else {
oe = append(oe, i.src.dialect.OrderedExpression(tmp, exp.AscDir, exp.NullsFirstSortType))
}
}
return
}
func (i *iterator) Scan(r dal.ValueSetter) (err error) {
if i.err != nil {
return i.err
}
if err = i.rows.Scan(i.scanBuf...); err != nil {
return err
}
if err = i.dst.table.Decode(i.scanBuf, r); err != nil {
return
}
return nil
}
func (i *iterator) Err() error {
return i.err
}
// Close iterator and cleanup
func (i *iterator) Close() error {
if i.rows == nil {
return nil
}
return i.rows.Close()
}
func (i *iterator) BackCursor(r dal.ValueGetter) (cur *filter.PagingCursor, err error) {
cur, err = i.collectCursorValues(r)
if err != nil {
return
}
// if this cursor is used, we need to reverse the sorting order
cur.ROrder = true
// if this cursor is used, we need to flip the conditional operator
// from less-then to greater-then
cur.LThen = i.sorting.Reversed()
return
}
func (i *iterator) ForwardCursor(r dal.ValueGetter) (*filter.PagingCursor, error) {
return i.collectCursorValues(r)
}
func (i *iterator) collectCursorValues(r dal.ValueGetter) (_ *filter.PagingCursor, err error) {
var (
cur = &filter.PagingCursor{LThen: i.sorting.Reversed()}
// @todo this will not work when using multiple primary keys!
pkUsed bool
value any
pKeys = make(map[string]bool)
)
for _, c := range i.dst.table.Columns() {
if c.IsPrimaryKey() {
attrIdent := c.Attribute().Ident
pKeys[attrIdent] = true
}
}
if len(pKeys) == 0 {
//return nil, fmt.Errorf("can not construct cursor without primary key attributes")
}
for _, c := range i.sorting {
if pKeys[c.Column] {
pkUsed = true
}
if value, err = r.GetValue(c.Column, 0); err != nil {
return
}
cur.Set(c.Column, value, c.Descending)
}
if !pkUsed {
for key := range pKeys {
value, err = r.GetValue(key, 0)
if err != nil {
return
}
cur.Set(key, value, false)
}
}
return cur, nil
}

View File

@@ -0,0 +1,170 @@
package dal_test
import (
"context"
"fmt"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/ql"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/store/adapters/rdbms"
"github.com/stretchr/testify/require"
"os"
"sort"
"testing"
_ "github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/mysql"
_ "github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/postgres"
_ "github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers/sqlite"
)
type (
// @todo refactor tests that use kv and migrate to kvv
// no need to keep and maintain both.
kv map[string]any
kvv map[string][]any
)
var (
s *rdbms.Store
)
// @todo refactor tests to follow rdbms/tests, using connectionInfo...
// @todo should be part of the general DAL testing suite
// @todo new ENV var for enabling(def)/disabling temp. model (table) creation (DB_PERSIST_MODELS=true)
func TestMain(m *testing.M) {
var (
dsn = os.Getenv("DB_DSN")
log = logger.MakeDebugLogger()
ctx = context.Background()
err error
aux store.Storer
)
if len(dsn) == 0 {
dsn = "sqlite3+debug://file::memory:?cache=shared&mode=memory"
}
// ctx = logger.ContextWithValue(context.Background(), log)
aux, err = store.Connect(ctx, log, dsn, true)
if err != nil {
fmt.Fprintf(os.Stdout, "could not connect: %v", err)
os.Exit(1)
}
s = aux.(*rdbms.Store)
m.Run()
}
// truncates table
func truncate(ctx context.Context, table string) error {
table = s.Dialect.QuoteIdent(table)
_, err := s.DB.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s", table))
return err
}
func (r kv) CountValues() map[string]uint {
out := make(map[string]uint)
for k := range r {
out[k]++
}
return out
}
func (r kv) GetValue(k string, place uint) (any, error) {
return r[k], nil
}
func (r kv) SetValue(k string, place uint, v any) error {
r[k] = v
return nil
}
// String function returns string representation of the kv with sorted keys
func (r kv) String() string {
// sort keys from map
keys := make([]string, 0, len(r))
for k := range r {
keys = append(keys, k)
}
sort.Strings(keys)
// build string by iterating over sorted keys and appending values
var out string
for i, k := range keys {
if i > 0 {
out += " "
}
out += fmt.Sprintf("%s=%v", k, r[k])
}
return out
}
func (r kvv) Set(k string, v ...any) kvv {
r[k] = v
return r
}
func (r kvv) CountValues() map[string]uint {
out := make(map[string]uint)
for k := range r {
out[k] = uint(len(r[k]))
}
return out
}
func (r kvv) GetValue(k string, p uint) (any, error) {
if r[k] == nil || len(r[k]) <= int(p) {
return nil, fmt.Errorf("kvv: out of bounds")
}
return r[k][p], nil
}
func (r kvv) SetValue(k string, p uint, v any) error {
if r[k] == nil {
r[k] = make([]any, 0, 1)
} else if len(r[k]) < int(p) {
r[k][p] = v
} else {
r[k] = append(r[k], v)
}
return nil
}
// String function returns string representation of the kv with sorted keys
func (r kvv) String() string {
// sort keys from map
keys := make([]string, 0, len(r))
for k := range r {
keys = append(keys, k)
}
sort.Strings(keys)
// build string by iterating over sorted keys and appending values
var out string
for i, k := range keys {
if i > 0 {
out += " "
}
out += fmt.Sprintf("%s=%v", k, r[k])
}
return out
}
func qlParse(req *require.Assertions, q string) *ql.ASTNode {
n, err := ql.NewParser().Parse(q)
req.NoError(err)
return n
}

View File

@@ -0,0 +1,805 @@
package dal
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/modern-go/reflect2"
"github.com/cortezaproject/corteza/server/pkg/dal"
"github.com/cortezaproject/corteza/server/pkg/filter"
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/drivers"
"github.com/cortezaproject/corteza/server/store/adapters/rdbms/ql"
"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
"github.com/jmoiron/sqlx"
)
type (
queryRunner interface {
sqlx.QueryerContext
sqlx.ExecerContext
}
model struct {
model *dal.Model
conn queryRunner
dialect drivers.Dialect
table drivers.TableCodec
}
parsedFilter interface {
ExpressionParsed() *ql.ASTNode
}
queryParser interface {
Convert(*ql.ASTNode) (out exp.Expression, err error)
Parse(string) (out exp.Expression, err error)
}
)
func validate(m *dal.Model) error {
var (
c2c = make(map[string]*dal.Attribute, len(m.Attributes))
)
for _, a := range m.Attributes {
if a.StoreIdent() == "" {
return fmt.Errorf("attribute %q has no ident", a.Ident)
}
if a.Store == nil {
return fmt.Errorf("attribute %q has no store codec", a.Ident)
}
usedBy, has := c2c[a.StoreIdent()]
if has {
// column already in the map
if a.Store.SingleValueOnly() {
return fmt.Errorf("attribute %q has single value codec but column %q is already used by attribute %q", a.Ident, a.StoreIdent(), usedBy.Ident)
}
}
c2c[a.StoreIdent()] = a
}
return nil
}
// Model returns fully initialized model store
//
// It abstracts database table and its columns and provides unified interface
// for fetching and storing records.
func Model(m *dal.Model, c queryRunner, d drivers.Dialect) *model {
var (
ms = &model{
model: m,
conn: c,
dialect: d,
table: drivers.NewTableCodec(m, d),
}
)
return ms
}
// parseQuery parses the query into the goqu expression
//
// The parse query initializes a fresh converter instance because QL converter
// uses some internal state to keep track of symbols and stuff.
//
// When doing parallel requests over the same model, unexpected... stuff happens.
//
// Alternative solution would introduce a mutes on the internal model but that
// is probably the same or worse as this.
func (d *model) parseQuery(q string) (out exp.Expression, err error) {
return d.QueryParser().Parse(q)
}
func (d *model) convertQuery(n *ql.ASTNode) (out exp.Expression, err error) {
return d.QueryParser().Convert(n)
}
// QueryParser returns ql struct that allows parsing query strings or converting AST into expression
// @todo benchmark to see if this re-init is a bad idea; I don't think it should be
//
// since we're just initializing fairly light structs.
func (d *model) QueryParser() queryParser {
return ql.Converter(
ql.SymHandler(d.qlConverterGenericSymHandler()),
ql.ValHandler(d.qlConverterGenericValHandler()),
ql.RefHandler(d.qlConverterGenericRefHandler()),
)
}
func (d *model) qlConverterGenericSymHandler() func(node *ql.ASTNode) (exp.Expression, error) {
return func(node *ql.ASTNode) (exp.Expression, error) {
// @note normalize system idents on the RDBMS level for filters
// offloaded to the database.
sym := dal.NormalizeAttrNames(node.Symbol)
if d.model.ResourceType == "corteza::compose:module" {
// temporary solution
//
// before DAL some fields were aliased (recordID => ID)
switch sym {
case "recordID":
sym = "ID"
}
}
if node.Meta == nil {
node.Meta = make(map[string]any)
}
attr := d.model.Attributes.FindByIdent(sym)
if attr == nil {
return nil, fmt.Errorf("unknown attribute %q used in query expression", node.Symbol)
}
node.Meta["dal.Attribute"] = attr
node.Meta["dal.Model"] = d.model
if !attr.Filterable {
return nil, fmt.Errorf("attribute %q can not be used in query expression", attr.Ident)
}
return d.table.AttributeExpression(sym)
}
}
func (d *model) qlConverterGenericValHandler() func(*ql.ASTNode) (exp.Expression, error) {
return func(node *ql.ASTNode) (exp.Expression, error) {
return d.dialect.ValHandler(node)
}
}
func (d *model) qlConverterGenericRefHandler() func(*ql.ASTNode, ...exp.Expression) (exp.Expression, error) {
return func(node *ql.ASTNode, args ...exp.Expression) (exp.Expression, error) {
return d.dialect.ExprHandler(node, args...)
}
}
func (d *model) Truncate(ctx context.Context) error {
sql, args, err := d.truncateSql().ToSQL()
if err != nil {
return err
}
_, err = d.conn.ExecContext(ctx, sql, args...)
return err
}
func (d *model) Create(ctx context.Context, rr ...dal.ValueGetter) error {
sql, args, err := d.insertSql(rr...).ToSQL()
if err != nil {
return err
}
_, err = d.conn.ExecContext(ctx, sql, args...)
return err
}
func (d *model) Update(ctx context.Context, r dal.ValueGetter) error {
sql, args, err := d.updateSql(r).ToSQL()
if err != nil {
return err
}
_, err = d.conn.ExecContext(ctx, sql, args...)
return err
}
func (d *model) Delete(ctx context.Context, r dal.ValueGetter) error {
sql, args, err := d.deleteSql(r).ToSQL()
if err != nil {
return err
}
_, err = d.conn.ExecContext(ctx, sql, args...)
return err
}
func (d *model) Search(f filter.Filter) (i *iterator, err error) {
var (
orderBy = f.OrderBy()
)
if f.Cursor() != nil {
// Page cursor exists; we need to validate it against used sort
// To cover the case when paging cursor is set but sorting is empty, we collect the sorting instructions
// from the cursor.
// This (extracted sorting info) is then returned as part of response
if orderBy, err = f.Cursor().Sort(orderBy); err != nil {
return nil, err
}
}
// validate order-by
for _, s := range orderBy {
if _, err = d.table.AttributeExpression(s.Column); err != nil {
return nil, err
}
if att := d.model.Attributes.FindByIdent(s.Column); att != nil && att.MultiValue {
return nil, fmt.Errorf("not allowed to sort by multi-value attribute: %s", s.Column)
}
}
// sanitize filter a bit
for _, c := range d.table.Columns() {
if !c.IsPrimaryKey() {
continue
}
attrIdent := c.Attribute().Ident
if orderBy.Get(attrIdent) != nil {
continue
}
if !c.Attribute().PrimaryKey && !c.Attribute().Sortable {
return nil, fmt.Errorf("can not sort by %q; not sortable, not primary key", attrIdent)
}
// Make sure results are always sorted at least by primary key
orderBy = append(orderBy, &filter.SortExpr{Column: attrIdent, Descending: orderBy.LastDescending()})
}
i = &iterator{
// source and destination is the same
src: d,
dst: d,
sorting: orderBy,
cursor: f.Cursor(),
limit: f.Limit(),
}
i.query = d.applyFiltersToQuery(d.selectSql(), f)
if err = i.query.Error(); err != nil {
return
}
return
}
func (d *model) Count(ctx context.Context, f filter.Filter) (c uint, err error) {
var (
aux = struct {
Count uint `db:"count"`
}{}
rows *sql.Rows
exprs []exp.Expression
q = d.dialect.GOQU().
From(d.table.Ident()).Select(goqu.COUNT(goqu.Star()).As("count"))
)
for ident, val := range f.Constraints() {
attrExpr, err := d.table.AttributeExpression(ident)
if err != nil {
return 0, err
}
exprs = append(exprs, exp.NewBooleanExpression(exp.EqOp, attrExpr, val))
}
query, args, err := q.Where(exprs...).Limit(1).ToSQL()
if err != nil {
return
}
rows, err = d.conn.QueryContext(ctx, query, args...)
if err != nil {
return
}
defer rows.Close()
if !rows.Next() {
return 0, errors.NotFound("not found")
}
if err = rows.Scan(&aux.Count); err != nil {
return
}
c = aux.Count
return c, nil
}
// Aggregate constructs SELECT sql with group-by and an optional having CLAUSE
//
// All group-by attributes are prepended to aggregation
// expressions when constructing expressions & columns to select from.
//
// Passing in filter with cursor, empty groupBy or aggrExpr slice will result in an error
func (d *model) Aggregate(f filter.Filter, groupBy []dal.AggregateAttr, aggrExpr []dal.AggregateAttr, having *ql.ASTNode) (i *iterator, err error) {
if len(groupBy) == 0 {
return nil, fmt.Errorf("can not run aggregation without group-by")
}
i = &iterator{
cursor: f.Cursor(),
sorting: f.OrderBy(),
limit: f.Limit(),
}
var (
// source model; how data we are reading from is shaped
srcModel = &dal.Model{}
// destination model; how data we are reading into is shaped
dstModel = &dal.Model{}
srcAttr, dstAttr *dal.Attribute
)
// prepare a bit modified module that
// describes aggregated columns (prepending attributes used for group-by)
for _, c := range append(groupBy, aggrExpr...) {
srcAttr = &dal.Attribute{
Ident: c.Identifier,
Type: c.Type,
Store: c.Store,
}
srcModel.Attributes = append(srcModel.Attributes, srcAttr)
dstAttr = &dal.Attribute{
Ident: c.Identifier,
Type: c.Type,
}
dstModel.Attributes = append(dstModel.Attributes, dstAttr)
}
i.src = Model(srcModel, d.conn, d.dialect)
i.dst = Model(dstModel, d.conn, d.dialect)
i.query = d.aggregateSql(f, groupBy, aggrExpr, having)
if err = i.query.Error(); err != nil {
return
}
return
}
func (d *model) Lookup(ctx context.Context, pkv dal.ValueGetter, r dal.ValueSetter) (err error) {
query, args, err := d.lookupSql(pkv).ToSQL()
if err != nil {
return
}
// using sql.Rows instead of a row
// this gives us more control over closing (the rows resource)
// and ability to use sql.RawBytes
var rows *sql.Rows
rows, err = d.conn.QueryContext(ctx, query, args...)
if err != nil {
return
}
if err = rows.Err(); err != nil {
return
}
defer rows.Close()
if !rows.Next() {
return errors.NotFound("not found")
}
scanBuf := d.table.MakeScanBuffer()
if err = rows.Scan(scanBuf...); err != nil {
return
}
if err = d.table.Decode(scanBuf, r); err != nil {
return
}
return rows.Close()
}
// converting parts of record filter into conditions for selecting records from a table
//
// Does not add any limits, sorting or any cursor conditions!
func (d *model) applyFiltersToQuery(base *goqu.SelectDataset, f filter.Filter) *goqu.SelectDataset {
var (
err error
tmp exp.Expression
cnd []exp.Expression
)
{
// Add model & namespace constraints when model expects (has configured attributes) for them
//
// This covers both scenarios:
// 1) Model is configured to store records in a dedicated table
// without model and/or namespace attributes
//
// 2) Model has model and/or namespace attribute and saves records
// from different modules in the same table
// if d.sysExprNamespaceID != nil {
// cnd = append(cnd, d.sysExprNamespaceID.Eq(f.NamespaceID))
// } else {
// // @todo check if f.NamespaceID is compatible
// }
//
// if d.sysExprModuleID != nil {
// cnd = append(cnd, d.sysExprModuleID.Eq(f.ModuleID))
// } else {
// // @todo check if f.ModuleID is compatible
// }
}
cc := f.Constraints()
if d.model.Constraints != nil {
if cc == nil {
cc = d.model.Constraints
} else {
for k, c := range d.model.Constraints {
// Overwrite user-provided constraints as the system ones are more important
cc[k] = c
}
}
}
for ident, vv := range cc {
attr := d.model.Attributes.FindByIdent(ident)
if attr == nil {
return base.SetError(fmt.Errorf("unknown attribute %q used for constrant", ident))
}
// @note why?
// if !attr.PrimaryKey {
// continue
// }
var attrExpr exp.Expression
attrExpr, err = d.table.AttributeExpression(attr.Ident)
if err != nil {
return base.SetError(err)
}
if len(vv) > 0 {
cnd = append(cnd, exp.NewBooleanExpression(exp.InOp, attrExpr, vv))
}
}
for ident, state := range f.StateConstraints() {
attr := d.model.Attributes.FindByIdent(ident)
if attr == nil {
if ident == "deletedAt" {
// workaround for situation where filter constraints
// contain deletedAt but attribute does not exist
continue
}
return base.SetError(fmt.Errorf("unknown attribute %q used for state constrant", ident))
}
if !attr.Type.IsNullable() {
// @todo this must be checked much earlier
return base.SetError(fmt.Errorf("can not use non-nullable attribute %q soft-deleting", attr.Ident))
}
if state == filter.StateInclusive {
// not used so we don't rea
continue
}
var attrExpr exp.Expression
attrExpr, err = d.table.AttributeExpression(attr.Ident)
if err != nil {
return base.SetError(err)
}
switch state {
case filter.StateExclusive:
// only not-null values
cnd = append(cnd, exp.NewLiteralExpression("? IS NOT NULL", attrExpr))
case filter.StateExcluded:
// exclude all non-null values
cnd = append(cnd, exp.NewLiteralExpression("? IS NULL", attrExpr))
}
}
if mc := f.MetaConstraints(); len(mc) > 0 {
attr := d.model.Attributes.FindByIdent("meta")
if attr == nil {
return base.SetError(fmt.Errorf("can not filter records in this module by meta: no meta attribute defined"))
}
var (
metaKeyExpr exp.Expression
metaAttrIdent = exp.NewIdentifierExpression("", d.model.Ident, attr.Ident)
)
for mKey, mVal := range f.MetaConstraints() {
metaKeyExpr, err = d.dialect.JsonExtractUnquote(metaAttrIdent, mKey)
if err != nil {
return base.SetError(err)
}
if reflect2.IsNil(mVal) {
cnd = append(cnd, exp.NewBooleanExpression(exp.IsNotOp, metaKeyExpr, nil))
} else {
cnd = append(cnd, exp.NewBooleanExpression(exp.EqOp, metaKeyExpr, mVal))
}
}
}
if pf, ok := f.(parsedFilter); ok {
tmp := pf.ExpressionParsed()
if tmp != nil {
n, err := d.convertQuery(tmp)
if err != nil {
return base.SetError(err)
}
cnd = append(cnd, n)
}
}
if q := strings.TrimSpace(f.Expression()); len(q) > 0 {
if tmp, err = d.parseQuery(q); err != nil {
return base.SetError(err)
}
cnd = append(cnd, tmp)
}
return base.Where(cnd...)
}
func (m *model) aggregateSql(f filter.Filter, groupBy []dal.AggregateAttr, out []dal.AggregateAttr, having *ql.ASTNode) (q *goqu.SelectDataset) {
// Get the base query and apply some filters
q = m.dialect.AggregateBase(m.table, groupBy, out)
q = m.applyFiltersToQuery(q, f)
var (
err error
expr exp.Expression
alias string
// store map alias-expression pairs to power-up
// HAVING clause query parsing (but only for the
// aggregation expression!)
a2expr = make(map[string]exp.Expression)
selected []any
field = func(c dal.AggregateAttr) (expr exp.Expression, err error) {
switch {
case len(c.RawExpr) > 0:
// @todo could probably be removed since RawExpr is only a temporary solution?
return m.parseQuery(c.RawExpr)
case c.Expression != nil:
return m.convertQuery(c.Expression)
}
return m.table.AttributeExpression(c.Identifier)
}
)
nc := m.dialect.Nuances()
for i, c := range groupBy {
if c.MultiValue {
if nc.ExpandedJsonColumnSelector != nil {
expr = nc.ExpandedJsonColumnSelector(c.RawExpr)
} else {
expr = goqu.C(c.RawExpr)
}
} else {
if expr, err = field(c); err != nil {
return q.SetError(err)
}
}
alias = c.Identifier
if alias == "" {
alias = fmt.Sprintf("group_by_%d", i)
}
a2expr[alias] = expr
// grouping by selected
q = q.GroupByAppend(alias)
expr = exp.NewAliasExpression(expr, alias)
// Add all group-by columns at the start of selection fields
selected = append(selected, expr)
}
q = q.Select(selected...)
for i, c := range out {
if expr, err = field(c); err != nil {
return q.SetError(err)
}
alias = c.Identifier
if alias == "" {
alias = fmt.Sprintf("aggr_%d", i)
}
a2expr[alias] = expr
expr = exp.NewAliasExpression(expr, alias)
q = q.SelectAppend(expr)
}
if having != nil {
var (
symHandler = m.qlConverterGenericSymHandler()
converter = ql.Converter(
// we need a more specialized symbol handler for the HAVING clause
// since it can contain aggregation expressions
ql.SymHandler(func(node *ql.ASTNode) (exp.Expression, error) {
sym := dal.NormalizeAttrNames(node.Symbol)
if a2expr[sym] != nil {
if m.dialect.Nuances().HavingClauseMustUseAlias {
// is aliased expression?
return a2expr[sym], nil
} else {
return exp.NewIdentifierExpression("", "", sym), nil
}
}
// if not, use the default handler
return symHandler(node)
}),
ql.RefHandler(m.qlConverterGenericRefHandler()),
)
)
// using special symbol handler that when converting HAVING clause expression
// this handler looks at the
if expr, err = converter.Convert(having); err != nil {
q.SetError(err)
return
}
q = q.Having(expr)
}
return
}
func (d *model) lookupSql(pkv dal.ValueGetter) *goqu.SelectDataset {
var (
sel = d.selectSql().Limit(1)
cond, err = d.pkLookupCondition(pkv)
)
if err != nil {
sel = sel.SetError(err)
}
return sel.Where(cond)
}
func (d *model) selectSql() *goqu.SelectDataset {
var (
cols = d.table.Columns()
// working around a bug inside goqu lib that adds
// * to the list of columns to be selected
// even if we clear the columns first
q = d.dialect.GOQU().
From(d.table.Ident())
)
if len(cols) == 0 {
return q.SetError(fmt.Errorf("can not create SELECT without columns"))
}
q = q.Select(d.table.Ident().Col(cols[0].Name()))
for _, col := range cols[1:] {
q = q.SelectAppend(d.table.Ident().Col(col.Name()))
}
return q
}
func (d *model) truncateSql() (_ *goqu.TruncateDataset) {
return d.dialect.GOQU().Truncate(d.table.Ident())
}
func (d *model) insertSql(rr ...dal.ValueGetter) (_ *goqu.InsertDataset) {
var (
ins = d.dialect.GOQU().Insert(d.table.Ident())
cc = d.table.Columns()
rows = make([][]any, len(rr))
cols = make([]any, len(cc))
err error
)
for c := range cc {
cols[c] = cc[c].Name()
}
for i, r := range rr {
rows[i], err = d.table.Encode(r)
if err != nil {
return ins.SetError(err)
}
}
return ins.Cols(cols...).Vals(rows...)
}
// updateSql generates SQL command for updating record
func (d *model) updateSql(r dal.ValueGetter) *goqu.UpdateDataset {
var (
upd = d.dialect.GOQU().Update(d.table.Ident())
values = exp.Record{}
condition = exp.Ex{}
encoded, err = d.table.Encode(r)
)
if err != nil {
return upd.SetError(err)
}
for i, c := range d.table.Columns() {
if c.IsPrimaryKey() {
// values[]
condition[c.Name()] = encoded[i]
} else {
values[c.Name()] = encoded[i]
}
}
return upd.Where(condition).Set(values)
}
func (d *model) deleteSql(pkv dal.ValueGetter) *goqu.DeleteDataset {
var (
del = d.dialect.GOQU().Delete(d.table.Ident())
cond, err = d.pkLookupCondition(pkv)
)
if err != nil {
del.SetError(err)
}
return del.Where(cond)
}
// Constructs primary-key-lookup expression from pk values
func (d *model) pkLookupCondition(pkv dal.ValueGetter) (_ exp.Expression, err error) {
var (
cnd = exp.NewExpressionList(exp.AndType)
val any
)
for _, c := range d.table.Columns() {
if !c.IsPrimaryKey() {
continue
}
val, err = pkv.GetValue(c.Name(), 0)
if err != nil {
return nil, fmt.Errorf("could not get value for primary key %q: %w", c.Name(), err)
}
cnd = cnd.Append(exp.NewBooleanExpression(exp.EqOp, d.table.Ident().Col(c.Name()), val))
}
return cnd, nil
}

View File

@@ -0,0 +1,760 @@
package dal_test
import (
"context"
"fmt"
"github.com/cortezaproject/corteza/server/pkg/dal"
"github.com/cortezaproject/corteza/server/pkg/filter"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/ql"
. "github.com/cortezaproject/corteza/server/store/adapters/rdbms/dal"
"github.com/spf13/cast"
"github.com/stretchr/testify/require"
"testing"
"time"
)
func TestModel_Search(t *testing.T) {
_ = logger.Default()
const (
items = 1000
)
var (
req = require.New(t)
ctx = context.Background()
baseModel = &dal.Model{
Ident: t.Name(),
Attributes: []*dal.Attribute{
{Ident: "item", Type: &dal.TypeText{}},
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
},
}
m = Model(baseModel, s.DB, s.Dialect)
i dal.Iterator
table, err = s.DataDefiner.ConvertModel(baseModel)
row kv
)
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Logf("Creating temporary table %q", table.Ident)
table.Temporary = true
req.NoError(s.DataDefiner.TableCreate(ctx, table))
req.NoError(truncate(ctx, table.Ident))
bm := time.Now()
ctx = context.Background() // no need to log inserts
count := 0
for i := 1; i <= items; i++ {
req.NoError(m.Create(ctx, &kv{
"item": fmt.Sprintf("i%d", i),
"group": fmt.Sprintf("g%d", i%1000),
"price": i,
"published": i%2 == 0,
}))
count++
}
t.Logf("Inserted %d entries in %v", count, time.Now().Sub(bm))
t.Log("Search through records, filter out published")
i, err = m.Search(filter.Generic(
filter.WithExpression("published"),
filter.WithOrderBy(filter.SortExprSet{
&filter.SortExpr{Column: "group"},
}),
))
req.NoError(err)
req.NotNil(i)
defer req.NoError(i.Close())
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Log("Iterating over results")
rows := make([]kv, 0, 5)
for i.Next(ctx) {
row = kv{}
req.NoError(i.Scan(row))
rows = append(rows, row)
}
req.NoError(i.Err())
req.Len(rows, items/2)
req.Equal("group=g0 item=i1000 price=1000 published=1", rows[0].String())
}
// Should be part of general DAL testing (not only RDBMS)
func TestModel_Search2(t *testing.T) {
var (
req = require.New(t)
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
baseModel = &dal.Model{
Ident: t.Name(),
Attributes: func() (aa []*dal.Attribute) {
s := &dal.CodecRecordValueSetJSON{Ident: "values"}
aa = []*dal.Attribute{
{Ident: "phyTxt", Type: &dal.TypeText{}},
{Ident: "phyNum", Type: &dal.TypeNumber{}},
{Ident: "phyBool", Type: &dal.TypeBoolean{}},
{Ident: "jsonEncTxt", Type: &dal.TypeText{}, Store: s},
{Ident: "jsonEncTxtMV", Type: &dal.TypeText{}, Store: s, MultiValue: true},
{Ident: "jsonEncNum", Type: &dal.TypeNumber{}, Store: s},
{Ident: "jsonEncNumMV", Type: &dal.TypeNumber{}, Store: s, MultiValue: true},
{Ident: "jsonEncBool", Type: &dal.TypeBoolean{}, Store: s},
{Ident: "jsonEncBoolMV", Type: &dal.TypeBoolean{}, Store: s, MultiValue: true},
}
// iterate through all attributsand set Filterable flag to true
for _, a := range aa {
a.Filterable = true
a.Sortable = true
}
return
}(),
}
m = Model(baseModel, s.DB, s.Dialect)
search = func(t *testing.T, f filter.Filter) []kv {
req := require.New(t)
i, err := m.Search(f)
req.NoError(err)
req.NotNil(i)
defer req.NoError(i.Close())
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
rows := make([]kv, 0, 5)
for i.Next(ctx) {
row := kv{}
req.NoError(i.Scan(row))
rows = append(rows, row)
}
req.NoError(i.Err())
return rows
}
)
table, err := s.DataDefiner.ConvertModel(baseModel)
req.NoError(err)
t.Logf("Creating temporary table %q", table.Ident)
table.Temporary = true
req.NoError(s.DataDefiner.TableCreate(ctx, table))
req.NoError(truncate(ctx, table.Ident))
{
noLogCtx := context.Background() // no need to log inserts
req.NoError(m.Create(noLogCtx, (&kvv{}).
Set("phyTxt", "bar").
Set("phyNum", 42).
Set("phyBool", false).
Set("jsonEncTxt", "bar").
Set("jsonEncTxtMV", "bar", "foo").
Set("jsonEncNum", 42).
Set("jsonEncNumMV", 21, 42).
Set("jsonEncBool", false).
Set("jsonEncBoolMV", false, true),
))
}
t.Run("comparing single value attributes", func(t *testing.T) {
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt = 'bar'"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt = 'baz'"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum = 42"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum = 21"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("!phyBool"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyBool"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxt = 'bar'"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxt = 'baz'"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNum = 42"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNum = 21"))), 0)
})
t.Run("comparing 1st value in multi value attributes", func(t *testing.T) {
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV = 'bar'"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV = 'baz'"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV = 'foo'"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV = 21"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV = 22"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV = 42"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncBoolMV"))), 0)
})
t.Run("comparing all values in multi value attributes", func(t *testing.T) {
req.Len(search(t, filter.Generic(filter.WithExpression("42 IN jsonEncNumMV"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("84 IN jsonEncNumMV"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("84 NOT IN jsonEncNumMV"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("'foo' IN jsonEncTxtMV"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("'baz' IN jsonEncTxtMV"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("true IN jsonEncBoolMV"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("false IN jsonEncBoolMV"))), 1)
})
t.Run("comparing all values in multi value attributes with another attribute", func(t *testing.T) {
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum IN jsonEncNumMV"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN jsonEncNumMV"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN jsonEncTxtMV"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyBool IN jsonEncTxtMV"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("phyBool IN jsonEncBoolMV"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN jsonEncBoolMV"))), 0)
})
t.Run("comparing with list of values", func(t *testing.T) {
t.Skip("this is unsupported on pkg/ql level")
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN (null, 'bar')"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyTxt IN (null, 'baz')"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum IN (null, 42)"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyNum IN (null, 21)"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("!phyBool IN (false))"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("phyBool IN (true))"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxt IN (null, 'bar')"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxt IN (null, 'baz')"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV IN (null, 'bar')"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV IN (null, 'baz')"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncTxtMV IN (null, 'foo')"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNum IN (null, 42)"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNum IN (null, 21)"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV IN (null, 21)"))), 1)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV IN (null, 22)"))), 0)
req.Len(search(t, filter.Generic(filter.WithExpression("jsonEncNumMV IN (null, 42)"))), 0)
})
}
func TestModel_Aggregate(t *testing.T) {
_ = logger.Default()
var (
req = require.New(t)
ctx = context.Background()
baseModel = &dal.Model{
Ident: t.Name(),
Attributes: []*dal.Attribute{
{Ident: "item", Type: &dal.TypeText{}},
{Ident: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "quantity", Type: &dal.TypeNumber{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Filterable: true},
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
},
}
m = Model(baseModel, s.DB, s.Dialect)
i dal.Iterator
table, err = s.DataDefiner.ConvertModel(baseModel)
row kv
)
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Logf("Creating temporary table %q", table.Ident)
table.Temporary = true
req.NoError(s.DataDefiner.TableCreate(ctx, table))
req.NoError(truncate(ctx, table.Ident))
req.NoError(m.Create(ctx, &kv{"item": "i1", "date": "2022-10-06", "group": "g1", "price": "1000", "quantity": "10", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i2", "date": "2022-10-06", "group": "g1", "price": "3000", "quantity": "30", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i3", "date": "2022-10-06", "group": "g2", "price": "4000", "quantity": "40", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i4", "date": "2022-10-06", "group": "g2", "price": "1000", "quantity": "10", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i5", "date": "2022-10-07", "group": "g2", "price": "1000", "quantity": "10", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i6", "date": "2022-10-07", "group": "g2", "price": "5000", "quantity": "50", "published": true}))
t.Log("Aggregating all records, calculating min & max price per group")
i, err = m.Aggregate(
filter.Generic(
filter.WithExpression("published"),
filter.WithOrderBy(filter.SortExprSet{
&filter.SortExpr{Column: "group", Descending: true},
&filter.SortExpr{Column: "date", Descending: false},
}),
),
// group-by
[]dal.AggregateAttr{
{Identifier: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
{Identifier: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
},
// aggregation expressions
[]dal.AggregateAttr{
{Identifier: "count", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
{Identifier: "max", RawExpr: "MAX(price)", Type: &dal.TypeNumber{}},
{Identifier: "min", RawExpr: "MIN(price)", Type: &dal.TypeNumber{}},
{Identifier: "avg", RawExpr: "AVG(price)", Type: &dal.TypeNumber{}},
{Identifier: "stock", RawExpr: "SUM(quantity)", Type: &dal.TypeNumber{}},
},
nil, // <== here be having condition
)
req.NoError(err)
req.NotNil(i)
defer req.NoError(i.Close())
t.Log("Iterating over results")
rows := make([]kv, 0, 3)
for i.Next(ctx) {
row = kv{}
req.NoError(i.Scan(row))
// due to difference of number of decimal digits in different DBs, we need to do this
// to make sure we get the same result
row["avg"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["avg"]))
row["stock"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["stock"]))
rows = append(rows, row)
}
req.NoError(i.Err())
req.Len(rows, 3)
req.Equal("avg=1000.00 count=1 date=2022-10-06 group=g2 max=1000 min=1000 stock=10.00", rows[0].String())
req.Equal("avg=5000.00 count=1 date=2022-10-07 group=g2 max=5000 min=5000 stock=50.00", rows[1].String())
req.Equal("avg=2000.00 count=2 date=2022-10-06 group=g1 max=3000 min=1000 stock=40.00", rows[2].String())
}
func TestModel_AggregationModelIdentifiers(t *testing.T) {
_ = logger.Default()
var (
req = require.New(t)
ctx = context.Background()
baseModel = &dal.Model{
Ident: "t",
Attributes: []*dal.Attribute{
{Ident: "group", Type: &dal.TypeText{}, Filterable: true},
},
}
m = Model(baseModel, s.DB, s.Dialect)
i dal.Iterator
table, err = s.DataDefiner.ConvertModel(baseModel)
row kv
)
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Logf("Creating temporary table %q", table.Ident)
table.Temporary = true
req.NoError(s.DataDefiner.TableCreate(ctx, table))
req.NoError(truncate(ctx, table.Ident))
req.NoError(m.Create(ctx, &kv{"group": "g1"}))
req.NoError(m.Create(ctx, &kv{"group": "g1"}))
req.NoError(m.Create(ctx, &kv{"group": "g2"}))
req.NoError(m.Create(ctx, &kv{"group": "g2"}))
req.NoError(m.Create(ctx, &kv{"group": "g2"}))
t.Log("Aggregating all records, calculating min & max price per group")
i, err = m.Aggregate(
filter.Generic(
filter.WithOrderBy(filter.SortExprSet{
&filter.SortExpr{Column: "grp", Descending: true},
}),
),
// group-by
[]dal.AggregateAttr{
{Identifier: "grp", RawExpr: "CONCAT(group, 'x')", Type: &dal.TypeText{}},
},
// aggregation expressions
[]dal.AggregateAttr{
{Identifier: "total", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
},
// having
qlParse(req, "total = 3"),
)
req.NoError(err)
req.NotNil(i)
defer req.NoError(i.Close())
t.Log("Iterating over results")
rows := make([]kv, 0)
for i.Next(ctx) {
row = kv{}
req.NoError(i.Scan(row))
rows = append(rows, row)
}
req.NoError(i.Err())
req.Len(rows, 1)
req.Equal("grp=g2x total=3", rows[0].String())
}
func TestModel_AggregateWithHaving(t *testing.T) {
_ = logger.Default()
var (
req = require.New(t)
ctx = context.Background()
baseModel = &dal.Model{
Ident: t.Name(),
Attributes: []*dal.Attribute{
{Ident: "item", Type: &dal.TypeText{}},
{Ident: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "quantity", Type: &dal.TypeNumber{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Filterable: true},
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
},
}
m = Model(baseModel, s.DB, s.Dialect)
i dal.Iterator
table, err = s.DataDefiner.ConvertModel(baseModel)
row kv
)
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Logf("Creating temporary table %q", table.Ident)
table.Temporary = true
req.NoError(s.DataDefiner.TableCreate(ctx, table))
req.NoError(truncate(ctx, table.Ident))
req.NoError(m.Create(ctx, &kv{"item": "i1", "date": "2022-10-06", "group": "g1", "price": "1000", "quantity": "0", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i2", "date": "2022-10-06", "group": "g1", "price": "3000", "quantity": "0", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i3", "date": "2022-10-06", "group": "g2", "price": "4000", "quantity": "40", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i4", "date": "2022-10-06", "group": "g2", "price": "1000", "quantity": "10", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i5", "date": "2022-10-07", "group": "g2", "price": "1000", "quantity": "10", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i6", "date": "2022-10-07", "group": "g2", "price": "5000", "quantity": "50", "published": true}))
t.Log("Aggregating all records, calculating min & max price per group, ignoring empty quantity")
i, err = m.Aggregate(
filter.Generic(
filter.WithExpression("published"),
filter.WithOrderBy(filter.SortExprSet{
&filter.SortExpr{Column: "group", Descending: true},
&filter.SortExpr{Column: "date", Descending: false},
}),
),
// group-by
[]dal.AggregateAttr{
{Identifier: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
{Identifier: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
},
// aggregation expressions
[]dal.AggregateAttr{
{Identifier: "count", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
{Identifier: "max", RawExpr: "MAX(price)", Type: &dal.TypeNumber{}},
{Identifier: "min", RawExpr: "MIN(price)", Type: &dal.TypeNumber{}},
{Identifier: "avg", RawExpr: "AVG(price)", Type: &dal.TypeNumber{}},
{Identifier: "stock", RawExpr: "SUM(quantity)", Type: &dal.TypeNumber{}},
},
// having
qlParse(req, "SUM(quantity) > 0"),
)
req.NoError(err)
req.NotNil(i)
defer req.NoError(i.Close())
t.Log("Iterating over results")
rows := make([]kv, 0, 3)
for i.Next(ctx) {
row = kv{}
req.NoError(i.Scan(row))
// due to difference of number of decimal digits in different DBs, we need to do this
// to make sure we get the same result
row["avg"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["avg"]))
row["stock"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["stock"]))
rows = append(rows, row)
}
req.NoError(i.Err())
req.Len(rows, 2)
req.Equal("avg=1000.00 count=1 date=2022-10-06 group=g2 max=1000 min=1000 stock=10.00", rows[0].String())
req.Equal("avg=5000.00 count=1 date=2022-10-07 group=g2 max=5000 min=5000 stock=50.00", rows[1].String())
}
func TestModel_AggregateHavingGroup(t *testing.T) {
_ = logger.Default()
var (
req = require.New(t)
ctx = context.Background()
baseModel = &dal.Model{
Ident: t.Name(),
Attributes: []*dal.Attribute{
{Ident: "item", Type: &dal.TypeText{}},
{Ident: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "grp", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true, Filterable: true},
{Ident: "quantity", Type: &dal.TypeNumber{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Filterable: true},
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
},
}
m = Model(baseModel, s.DB, s.Dialect)
i dal.Iterator
table, err = s.DataDefiner.ConvertModel(baseModel)
row kv
)
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Logf("Creating temporary table %q", table.Ident)
table.Temporary = true
req.NoError(s.DataDefiner.TableCreate(ctx, table))
req.NoError(truncate(ctx, table.Ident))
req.NoError(m.Create(ctx, &kv{"item": "i1", "date": "2022-10-06", "grp": "g1", "price": "1000", "quantity": "10", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i2", "date": "2022-10-06", "grp": "g1", "price": "3000", "quantity": "30", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i3", "date": "2022-10-06", "grp": "g2", "price": "4000", "quantity": "40", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i4", "date": "2022-10-06", "grp": "g2", "price": "1000", "quantity": "10", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i5", "date": "2022-10-07", "grp": "g2", "price": "1000", "quantity": "10", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i6", "date": "2022-10-07", "grp": "g2", "price": "5000", "quantity": "50", "published": true}))
t.Log("Aggregating all records, calculating min & max price per group")
i, err = m.Aggregate(
filter.Generic(),
// group-by
[]dal.AggregateAttr{
{Identifier: "agg", Expression: &ql.ASTNode{Symbol: "grp"}, Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
},
// aggregation expressions
[]dal.AggregateAttr{
{Identifier: "count", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
},
qlParse(req, "agg = 'g1'"),
)
req.NoError(err)
req.NotNil(i)
defer req.NoError(i.Close())
t.Log("Iterating over results")
rows := make([]kv, 0, 1)
for i.Next(ctx) {
row = kv{}
req.NoError(i.Scan(row))
rows = append(rows, row)
}
req.NoError(i.Err())
req.Len(rows, 1)
req.Equal("agg=g1 count=2", rows[0].String())
}
func TestModel_Distinct(t *testing.T) {
_ = logger.Default()
var (
req = require.New(t)
ctx = context.Background()
baseModel = &dal.Model{
Ident: t.Name(),
Attributes: []*dal.Attribute{
{Ident: "item", Type: &dal.TypeText{}},
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
},
}
m = Model(baseModel, s.DB, s.Dialect)
i dal.Iterator
table, err = s.DataDefiner.ConvertModel(baseModel)
row kv
)
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Logf("Creating temporary table %q", table.Ident)
table.Temporary = true
req.NoError(s.DataDefiner.TableCreate(ctx, table))
req.NoError(truncate(ctx, table.Ident))
req.NoError(m.Create(ctx, &kv{"item": "i1", "group": "g1", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i2", "group": "g1", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i3", "group": "g2", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i4", "group": "g2", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i5", "group": "g3", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i6", "group": "g3", "published": false}))
t.Log("Aggregating all records, returning distinct groups")
i, err = m.Aggregate(
filter.Generic(
filter.WithExpression("published"),
filter.WithOrderBy(filter.SortExprSet{
&filter.SortExpr{Column: "group"},
})),
// group-by
[]dal.AggregateAttr{
{Identifier: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
},
nil,
nil, // <== here be having condition
)
req.NoError(err)
req.NotNil(i)
defer req.NoError(i.Close())
t.Log("Iterating over results")
rows := make([]kv, 0, 2)
for i.Next(ctx) {
row = kv{}
req.NoError(i.Scan(row))
rows = append(rows, row)
}
req.NoError(i.Err())
req.Len(rows, 2)
req.Equal("group=g1", rows[0].String())
req.Equal("group=g2", rows[1].String())
}
func TestModel_AggregateWithCursors(t *testing.T) {
_ = logger.Default()
var (
req = require.New(t)
ctx = context.Background()
baseModel = &dal.Model{
Ident: t.Name(),
Attributes: []*dal.Attribute{
{Ident: "item", Type: &dal.TypeText{}},
{Ident: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Sortable: true},
{Ident: "quantity", Type: &dal.TypeNumber{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}, Filterable: true},
{Ident: "price", Type: &dal.TypeNumber{}, Filterable: true},
{Ident: "published", Type: &dal.TypeBoolean{}, Filterable: true},
},
}
m = Model(baseModel, s.DB, s.Dialect)
i dal.Iterator
table, err = s.DataDefiner.ConvertModel(baseModel)
row kv
cur *filter.PagingCursor
results = []string{
"avg=1000.00 count=1 date=2022-10-06 group=g2 max=1000 min=1000 stock=10.00",
"avg=5000.00 count=1 date=2022-10-07 group=g2 max=5000 min=5000 stock=50.00",
"",
}
)
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Logf("Creating temporary table %q", table.Ident)
table.Temporary = true
req.NoError(s.DataDefiner.TableCreate(ctx, table))
req.NoError(truncate(ctx, table.Ident))
req.NoError(m.Create(ctx, &kv{"item": "i1", "date": "2022-10-06", "group": "g1", "price": "1000", "quantity": "0", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i2", "date": "2022-10-06", "group": "g1", "price": "3000", "quantity": "0", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i3", "date": "2022-10-06", "group": "g2", "price": "4000", "quantity": "40", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i4", "date": "2022-10-06", "group": "g2", "price": "1000", "quantity": "10", "published": true}))
req.NoError(m.Create(ctx, &kv{"item": "i5", "date": "2022-10-07", "group": "g2", "price": "1000", "quantity": "10", "published": false}))
req.NoError(m.Create(ctx, &kv{"item": "i6", "date": "2022-10-07", "group": "g2", "price": "5000", "quantity": "50", "published": true}))
// fetching two pages
for p, result := range results {
t.Logf("#%d Aggregating all records, calculating min & max price per group, ignoring empty quantity", p+1)
i, err = m.Aggregate(
filter.Generic(
filter.WithCursor(cur),
filter.WithLimit(1),
filter.WithExpression("published"),
filter.WithOrderBy(filter.SortExprSet{
&filter.SortExpr{Column: "group", Descending: true},
&filter.SortExpr{Column: "date", Descending: false},
}),
),
// group-by
[]dal.AggregateAttr{
{Identifier: "date", Type: &dal.TypeDate{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
{Identifier: "group", Type: &dal.TypeText{}, Store: &dal.CodecRecordValueSetJSON{Ident: "values"}},
},
// aggregation expressions
[]dal.AggregateAttr{
{Identifier: "count", RawExpr: "COUNT(*)", Type: &dal.TypeNumber{}},
{Identifier: "max", RawExpr: "MAX(price)", Type: &dal.TypeNumber{}},
{Identifier: "min", RawExpr: "MIN(price)", Type: &dal.TypeNumber{}},
{Identifier: "avg", RawExpr: "AVG(price)", Type: &dal.TypeNumber{}},
{Identifier: "stock", RawExpr: "SUM(quantity)", Type: &dal.TypeNumber{}},
},
// having
qlParse(req, "SUM(quantity) > 0"),
)
req.NoError(err)
req.NotNil(i)
defer req.NoError(i.Close())
// uncomment to se generated query
ctx = logger.ContextWithValue(context.Background(), logger.MakeDebugLogger())
t.Logf("#%d Iterating over results", p+1)
rows := make([]kv, 0, 3)
for i.Next(ctx) {
row = kv{}
req.NoError(i.Scan(row))
// due to difference of number of decimal digits in different DBs, we need to do this
// to make sure we get the same result
row["avg"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["avg"]))
row["stock"] = fmt.Sprintf("%.2f", cast.ToFloat64(row["stock"]))
rows = append(rows, row)
}
req.NoError(i.Err())
if len(result) == 0 {
req.Len(rows, 0)
} else {
req.Len(rows, 1)
req.Equal(result, rows[0].String())
cur, err = i.ForwardCursor(row)
req.NotNil(cur)
req.NoError(err)
}
}
}