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
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:
559
server/store/adapters/rdbms/dal/connection.go
Normal file
559
server/store/adapters/rdbms/dal/connection.go
Normal 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
|
||||
}
|
||||
323
server/store/adapters/rdbms/dal/iterator.go
Normal file
323
server/store/adapters/rdbms/dal/iterator.go
Normal 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
|
||||
}
|
||||
170
server/store/adapters/rdbms/dal/main_test.go
Normal file
170
server/store/adapters/rdbms/dal/main_test.go
Normal 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
|
||||
}
|
||||
805
server/store/adapters/rdbms/dal/model.go
Normal file
805
server/store/adapters/rdbms/dal/model.go
Normal 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
|
||||
}
|
||||
760
server/store/adapters/rdbms/dal/model_test.go
Normal file
760
server/store/adapters/rdbms/dal/model_test.go
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user