diff --git a/config/ci_test.yaml.example b/config/ci_test.yaml.example index eaac4332129dde3d194ebeab01eb4d415d2a6ec7..178999eab5bfbc82269149dc6b3e3b946e7b0489 100644 --- a/config/ci_test.yaml.example +++ b/config/ci_test.yaml.example @@ -211,9 +211,3 @@ dimensions: parent: "dim:0" relation: "year" description: "A dimension of Blendb. Has 1 possible value." - - - name: "dim:12" - dataType: "integer" - parent: "dim:0" - relation: "dayofweek" - description: "A dimension of Blendb. Has 7 possible values." diff --git a/package.json b/package.json index abc4fca8573142f57681791ca0c40102f6212cb7..373398c84c0a5c02e70ffaa98e5ca758e4a3d096 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,11 @@ "@types/chai": "^3.4.33", "@types/express": "^4.0.33", "@types/js-yaml": "^3.5.29", - "@types/pg": "^6.1.38", + "@types/pg": "^6.1.45", "async": "=2.4.1", "express": "^4.0.33", "js-yaml": "^3.8.2", + "monetdb": "^1.1.4", "osprey": "^0.3.2", "pg": "^6.1.5", "ts-node": "^3.1.0", diff --git a/src/adapter/monet.ts b/src/adapter/monet.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa01de01d99b7bc5d833340799886ba6e670e14a --- /dev/null +++ b/src/adapter/monet.ts @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2018 Centro de Computacao Cientifica e Software Livre + * Departamento de Informatica - Universidade Federal do Parana + * + * This file is part of blend. + * + * blend is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * blend is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with blend. If not, see <http://www.gnu.org/licenses/>. + */ + +import { SQLAdapter } from "./sql"; +import { View } from "../core/view"; +import { FilterOperator } from "../core/filter"; +const MDB = require("monetdb")(); + +export interface MonetConfig { + host: string; + port: number; + dbname: string; + user: string; + password: string; +} + +interface MonetResult { + data: any[]; + structure: {type: string, column: string, index: number}[]; +} + +export class MonetAdapter extends SQLAdapter { + private config: MonetConfig; + + constructor (conf: MonetConfig) { + super(); + this.config = conf; + } + public getDataFromView(view: View, cb: (error: Error, result?: any[]) => void): void { + const query = this.getQueryFromView(view); + + let pool: any = new MDB(this.config); + pool.connect(); + pool.query(query).then((result: MonetResult) => { + if (result) { + let res = result.data.map((item) => { + let obj: any = {}; + for (let i = 0; i < result.structure.length; ++i) { + let struct = result.structure[i]; + if (struct.type === "timestamp") { + obj[struct.column] = new Date(item[struct.index]); + } + else { + obj[struct.column] = item[struct.index]; + } + } + return obj; + }); + cb(null, res); + } + else { + cb(null, null); + } + }).fail((err: Error) => { + cb(err, null); + }); + pool.close(); + } + + public materializeView(view: View): boolean { + return false; + } + + protected typeCast(quotedValue: string, dt: string): string { + switch (dt) { + case "date": + return "CAST(" + quotedValue + " AS TIMESTAMP)"; + case "integer": + return "CAST(" + quotedValue + " AS INTEGER)"; + case "boolean": + return "CAST(" + quotedValue + " AS BOOLEAN)"; + default: + return quotedValue; + } + } + + protected applyOperator(lSide: string, rSide: string, op: FilterOperator): string { + switch (op) { + case FilterOperator.EQUAL: + return lSide + " = " + rSide; + case FilterOperator.NOTEQUAL: + return "NOT(" + lSide + " = " + rSide + ")"; + case FilterOperator.GREATER: + return lSide + " > " + rSide; + case FilterOperator.LOWER: + return lSide + " < " + rSide; + case FilterOperator.GREATEREQ: + return lSide + " >= " + rSide; + case FilterOperator.LOWEREQ: + return lSide + " <= " + rSide; + default: + return ""; + } + } +} diff --git a/src/adapter/postgres.spec.ts b/src/adapter/postgres.spec.ts index 597692c0ec4cdd718bc148ac4808a30113b5f162..e9b1cafe35ff475761fc4b45f04edbeaf13016de 100644 --- a/src/adapter/postgres.spec.ts +++ b/src/adapter/postgres.spec.ts @@ -21,8 +21,10 @@ import { expect } from "chai"; import { PostgresAdapter } from "./postgres"; +import { MonetAdapter, MonetConfig } from "./monet"; import { Adapter } from "../core/adapter"; -import { Fixture } from "../../test/postgres/fixture"; +import { Fixture as FixPostgres } from "../../test/postgres/fixture"; +import { Fixture as FixMonet } from "../../test/monet/fixture"; import { ConfigParser } from "../util/configParser"; import { adapterScenario } from "../../test/scenario"; @@ -33,16 +35,39 @@ describe("postgres adapter", () => { let config: any; let adapter: Adapter; let fixture; - before((done) => { + before(function (done) { + // Arrow function not used to get acces to this and skip the test config = ConfigParser.parse("config/test.yaml"); - fixture = new Fixture(config.connection); - fixture.load(config.loadViews, config.struct.create, (err) => { - if (err) { - throw err; - } - adapter = new PostgresAdapter(config.connection); - done(); - }); + if (config.adapter === "postgres") { + fixture = new FixPostgres(config.connection); + fixture.load(config.loadViews, config.struct.create, (err) => { + if (err) { + throw err; + } + adapter = new PostgresAdapter(config.connection); + done(); + }); + } + else if (config.adapter === "monet") { + fixture = new FixMonet(config.connection); + fixture.load(config.loadViews, config.struct.create, (err) => { + if (err) { + throw err; + } + let parsedConfig: MonetConfig = { + user: config.connection.user, + dbname: config.connection.database, + password: config.connection.password, + host: config.connection.host, + port: config.connection.port + }; + adapter = new MonetAdapter(parsedConfig); + done(); + }); + } + else { + this.skip(); + } }); // Tests it("should get data from single materialized view", (done) => { diff --git a/src/adapter/postgres.ts b/src/adapter/postgres.ts index 436b482eaa930966db202cff5e2c69ddae1e8fd9..3566542b2d1111ecaa8b8d625d1fc914925ff94b 100644 --- a/src/adapter/postgres.ts +++ b/src/adapter/postgres.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Centro de Computacao Cientifica e Software Livre + * Copyright (C) 2018 Centro de Computacao Cientifica e Software Livre * Departamento de Informatica - Universidade Federal do Parana * * This file is part of blend. @@ -18,37 +18,12 @@ * along with blend. If not, see <http://www.gnu.org/licenses/>. */ -import { Adapter } from "../core/adapter"; -import { Metric } from "../core/metric"; -import { Dimension } from "../core/dimension"; -import { Clause } from "../core/clause"; -import { Filter, FilterOperator } from "../core/filter"; -import { AggregationType, RelationType } from "../common/types"; +import { SQLAdapter } from "./sql"; import { View } from "../core/view"; +import { FilterOperator } from "../core/filter"; import { Pool, PoolConfig } from "pg"; -interface ExpandedView { - dimensions: Dimension[]; - metrics: Metric[]; - dimMetrics: Metric[]; - keys: Dimension[]; - clauses: Clause[]; - from: string; - id: string; - origin: boolean; -} - -interface DimInfo { - dim: Dimension; - views: ExpandedView[]; -} - -interface DimTranslation { - aliased: string; - noalias: string; -} - -export class PostgresAdapter extends Adapter { +export class PostgresAdapter extends SQLAdapter { private pool: Pool; constructor (config: PoolConfig) { @@ -75,809 +50,35 @@ export class PostgresAdapter extends Adapter { return false; } - public getQueryFromView(view: View): string { - /* - Find the base (materialized) views that has this data and exapand - than (make a parse to the format used in the adapter) - */ - const materialized = this.searchMaterializedViews(view).sort((a, b) => { - return (a.id < b.id) ? -1 : 1; - }).map((item) => { - return { - id: item.id, - from: "view_" + item.id, - dimMetrics: [], - metrics: item.metrics.filter((i) => { - return view.metrics.some((j) => i.name === j.name); - }), - dimensions: item.dimensions, - keys: item.keys, - clauses: item.clauses, - origin: item.origin - }; - }); - - // Remove repeated views from the result - let partialJoin = [materialized[0]]; - for (let i = 1; i < materialized.length; ++i) { - if (materialized[i - 1].id !== materialized[i].id) { - partialJoin.push(materialized[i]); - } - } - - const blackList = view.dimensions.map((i) => i.name); - for (let i = 0; i < view.clauses.length; ++i) { - if (view.clauses[i].filters.length === 1) { - let filter = view.clauses[i].filters[0]; - if (filter.operator === FilterOperator.EQUAL) { - blackList.push(filter.target.name); - } - } - } - - /* - If there is more than one source of data (tables/views) - a join is needed. - - Partial Join represents how many sources still exists, - every join reduces this number. - */ - let clausesToCover = view.clauses.map((i) => i); - while (partialJoin.length > 1) { - /* - Variable map finds what dimenensions are still needed to - complete this query, they are required for 2 reasons. - 1 - To make joins - 2 - Because they are in the query - - For each view that has this dimension we add one score to - this dimension, if they are in the query the same. - - Automatically if the dimension is in the query there will be - at least one view with this atribute (or the query could not be - completed) so dimensions in the query always have score of - at least 2. - - To make a join the dimension must be in 2 different views, - creating a score of 2 either. - - If the score is less than 2 so this dimension is not required - anymore and can be removed. - */ - let map: { [key: string]: number } = {}; - let partialsChange = false; - for (let i = 0; i < partialJoin.length; ++i) { - const dims = partialJoin[i].dimensions; - for (let k = 0; k < dims.length; ++k) { - if (!map[dims[k].name]) { - map[dims[k].name] = 1; - } - - else { - ++map[dims[k].name]; - } - } - } - - for (let i = 0; i < view.dimensions.length; ++i) { - let dim = view.dimensions[i]; - while (dim !== null) { - if (map[dim.name]) { - ++map[dim.name]; - } - dim = dim.parent; - } - } - - /* - Also mark scores for dimensions inside clauses - */ - for (let i = 0; i < clausesToCover.length; ++i) { - for (let j = 0; j < clausesToCover[i].targets.length; ++j) { - if (map[clausesToCover[i].targets[j].name]) { - ++map[clausesToCover[i].targets[j].name]; - } - } - } - - for (let i = 0; i < partialJoin.length; ++i) { - const dims = partialJoin[i].dimensions.filter((item) => { - return map[item.name] > 1; - }); - const keys = partialJoin[i].keys.filter((item) => { - return map[item.name] > 1; - }); - /* - At this point the dimensions with less than score 2 - are removed, if this happens the view is agreggated - again, with less dimensions, removing this dimension - from the view. - */ - - let coveredClauses: Clause[] = []; - let notCoveredClauses: Clause[] = []; - /* - If all dimensions in a clause are a sub set of the - dimensions of a view, this clause is apllied now, - propagating the clause to this point. - - Then this clause is removed from the set of clauses - */ - for (let j = 0; j < clausesToCover.length; ++j) { - if (clausesToCover[j].isCovered(partialJoin[i].dimensions)) { - coveredClauses.push(clausesToCover[j]); - } - else { - notCoveredClauses.push(clausesToCover[j]); - } - } - clausesToCover = notCoveredClauses.filter((clause) => { - return !partialJoin[i].clauses.some((c) => c.id === clause.id); - }); - if (dims.length < partialJoin[i].dimensions.length || coveredClauses.length > 0) { - const partial = new View({ - metrics: partialJoin[i].metrics, - dimensions: dims, - keys: keys, - origin: false, - clauses: coveredClauses.concat(partialJoin[i].clauses), - materialized: false - }); - const from = "(" + - this.buildQuery(partial, [partialJoin[i]]) + - ") AS view_" + partial.id + "\n"; - - partialJoin[i].id = partial.id; - partialJoin[i].dimensions = partial.dimensions; - partialJoin[i].keys = partial.keys; - partialJoin[i].origin = partial.origin; - partialJoin[i].from = from; - - partialsChange = true; - } - } - /* - If at least one of the views changed (have the number of - dimensions reduced) returns to the begining of the loop - again. - - Othewise we need to make a join. - */ - if (!partialsChange) { - /* - Sorting the views by keys. - If the keys are identical, then they - will be in sequence, and views with identical - keys can be joined. - - Sort an array of keys is the same as sort a - array of strings. - */ - const sorted = partialJoin.sort((a, b) => { - return this.compareKeys(a.keys, b.keys, blackList); - }); - /* - SUPER WARNING: WHEN THE BLACK LIST IS USED THE VIEW IS - UNMATERIALIZEBLE, BUT THE QUERY CAN AGGREGATE THE VALUES - - The blackList is the array of dimensions of the query plus - the dimensions in filters using the equality operator. - In further coments is expained that the relation to make - a join must be one-to-one between the tables. - However and a dimension is choosed, a sub view is - created and if the relation is preserved in the sub view - the query can be agregated, but this view cannot be re-used - so it is unmaterializeble. - - The equality operator is the same as select one subview. - */ - /* - First of all, the remaining views are splited in segments. - - A segment contains views with the same keys that are great - to make joins. Joins like this do not create "dimensional - metrics". - - In joins like this one row of each view will be connected - with at most one row of each other table. - */ - const segment = [[sorted[0]]]; - let segmentId = 0; - for (let i = 1; i < sorted.length; ++i) { - if (this.compareKeys(sorted[i - 1].keys, sorted[i].keys, blackList) === 0) { - segment[segmentId].push(sorted[i]); - } - else { - ++segmentId; - segment.push([sorted[i]]); - } - } - - partialJoin = []; - let ableToJoin = false; - - for (let i = 0; i < segment.length; ++i) { - /* - If a segment has more than one view, a join can be made - */ - if (segment[i].length > 1) { - let mets: Metric[] = []; - let clauses: Clause[] = []; - let dims: Dimension[] = []; - let dimMetrics: Metric[] = []; - for (let j = 0; j < segment[i].length; ++j) { - mets = mets.concat(segment[i][j].metrics); - clauses = clauses.concat(segment[i][j].clauses); - dims = dims.concat(segment[i][j].dimensions); - dimMetrics = dimMetrics.concat(segment[i][j].dimMetrics); - } - - dims = this.removeDuplicatedDimensions(dims); - /* - Its atributes are just concatenated and the - duplicates removed. - */ - - const partial = new View({ - metrics: mets, - dimensions: dims, - keys: segment[i][0].keys, - origin: false, - clauses: clauses, - materialized: false - }); - const viewFrom = "(" + - this.buildQuery(partial, segment[i]) + - ") AS view_" + partial.id + "\n"; - - partialJoin.push({ - id: partial.id, - from: viewFrom, - dimMetrics: dimMetrics, - metrics: partial.metrics, - dimensions: partial.dimensions, - keys: partial.keys, - clauses: partial.clauses, - origin: partial.origin - }); - - ableToJoin = true; - } - - else { - /* - If the segment has just one view, anything can be - done at this point, so just reinsert this view in - set of views. - */ - partialJoin.push(segment[i][0]); - } - } - - /* - If at least one join was made in the last part (a segment - with more than one view) than return to the begining of the - loop. - - This permits after a join remove the dimensions that were - only choosen to this join, and are no longer required - - Ideally the joins should be restrict the join method used - above, but in some cases this can not be done. - - So if all the segments have only one view inside, move - to the next method. - */ - if (!ableToJoin) { - /* - At this point 2 views will be joined, first the - similarity with each pair of views is calculated, - the pair with the biggedt similarity will be joined. - - Similarity is calculated with the number of common - dimensions in the keys. - */ - let similarity = 0; - let idx0 = 0; - let idx1 = 1; - for (let i = 0; i < partialJoin.length; ++i) { - for (let j = i + 1 ; j < partialJoin.length; ++j) { - const pi = partialJoin[i].keys; - const pj = partialJoin[j].keys; - let score = this.similarDimensions (pi, pj); - if (similarity < score) { - similarity = score; - idx0 = i; - idx1 = j; - } - } - } - - const partial0 = partialJoin[idx0]; - const partial1 = partialJoin[idx1]; - - partialJoin.splice(idx1, 1); - partialJoin.splice(idx0, 1); - - /* - Once the views are select they are joined with the - same method, concatenedted its atributes and - removing duplicates, however the nasty effect of - this join is the creation of "dimensional metrics". - - "Dimensional metrics" are metrics that can no longer - be aggregated, and at this point to the end - of a query they will act as dimensions. - - This change happens to avoid inconsistency generated - by a join where one row of one table can be connected - to more than one of other table. - - Take this example. - - View0 : metrics [met0], dimensions [dim0] - values: [{met0: 10, dim0: 1}] - View1 : metrics [met1], dimensions [dim2] - values: [{met1: 10, dim2: 1}. {met1: 5, dim2: 2}] - View2 : metrics [], dimensions [dim0, dim1, dim2] - values: [ - {dim0: 1, dim1: 1, dim2: 1}, - {dim0: 1, dim1: 1, dim2: 2} - ] - The query is metrics [met0, met1] and dimensions [dim1] - First a join of View0 and View1 is made, the result - is: [ - {dim0: 1, dim1: 1, dim2: 1, met0: 10}, - {dim0: 1, dim1: 1, dim2: 2, met0: 10} - ] - Note that the value of met0 is duplicated. - Now dim0 is removed, than joined with view2 resulting - in: [ - {met1: 10, dim1: 1, dim2: 1, met0: 10}, - {met1: 5 , dim1: 1, dim2: 2, met0: 10} - ] - - Lets assume that the agregation is SUM - If we remove dim2 and re-agregate the result is: [ - {met1: 15, dim1: 1, met0: 20} - ] - - This result is wrong. The replication of the value - met0 affects the result. - - See if met1 was not required, first the dimemnsion would - be reduced, left dim0 and dim1, than joined that reduced - again resulting in the value [ - {dim1:1, met0: 10} - ] - - Is this case there is no duplication and the aggregation - does not include more rows than should. - - To solve this problem the met0 must become a dimension, - in other words, not aggregated again. If the met0 was - not agregated in the query met0, met1, dim1 the result - is: [ - {met1: 15, dim1: 1, met0: 10} - ] - what is compatible. - - After this extreme long explanation what must be - known is: Joining views with diferent keys - generate "dimensional metrics". - - Views with "dimensional metrics" can not used for future - queries because can not be re-agregated, so this must be - avoided and is one-query only views. - */ - - let dimMetrics: Metric[]; - let mets: Metric[]; - let dims = partial0.dimensions.concat(partial1.dimensions); - dims = this.removeDuplicatedDimensions(dims); - let keys = partial0.keys.concat(partial1.keys); - keys = this.removeDuplicatedDimensions(keys); - if (partial0.keys.length === similarity) { - /* - Here the metrics become dimensions, but the effect - can be reduced. If the keys of partial0 - is a sub set of the keys ou partial1 - than the number of rows of partial 1 is not - affected, in other words the metrics of partial1 - can be aggregated and does not need to become - dimensions. - */ - partial0.dimMetrics = partial0.dimMetrics.concat(partial0.metrics); - partial0.metrics = []; - mets = partial1.metrics; - } - - else if (partial1.keys.length === similarity) { - /* - The same occurs if the keys of partia1 is a subset - of partial0. - */ - partial1.dimMetrics = partial1.dimMetrics.concat(partial1.metrics); - partial1.metrics = []; - mets = partial0.metrics; - } - - else { - /* - But if there is no sub set, than both sides have - the metrics turned in dimensions. - */ - partial0.dimMetrics = partial0.dimMetrics.concat(partial0.metrics); - partial0.metrics = []; - partial1.dimMetrics = partial1.dimMetrics.concat(partial1.metrics); - partial1.metrics = []; - mets = []; - } - - dimMetrics = partial0.dimMetrics.concat(partial1.dimMetrics); - const partial = new View({ - metrics: mets, - dimensions: dims, - keys: keys, - origin: false, - clauses: partial0.clauses.concat(partial1.clauses), - materialized: false - }); - const id = new View({ - metrics: mets.concat(dimMetrics), - dimensions: dims, - keys: keys, - origin: false, - clauses: partial0.clauses.concat(partial1.clauses), - materialized: false - }).id; - const viewFrom = "(" + - this.buildQuery(partial, [partial0, partial1]) + - ") AS view_" + id + "\n"; - partialJoin.push({ - id: id, - from: viewFrom, - dimMetrics: dimMetrics, - metrics: mets, - dimensions: dims, - keys: keys, - clauses: partial.clauses, - origin: false - }); - - } - } - } - - /* - When only one view remain, the query is made and a ; - is added at the end. - - TODO: Probrably this last line adds one more - layer to the query, that is in fact unnecessary. - Think a way to remove-it. - */ - return this.buildQuery(view, partialJoin) + ";"; - } - - private searchMaterializedViews(view: View): View[] { - let r: View[] = []; - if (view.materialized) { - return [view]; - } - - else { - let children = view.childViews; - for (let i = 0; i < children.length; ++i) { - r = r.concat(this.searchMaterializedViews(children[i])); - } - } - - return r; - } - - private buildQuery(target: View, views: ExpandedView[]) { - const metrics = target.metrics; - const dimensions = target.dimensions; - const clauses = target.clauses; - - let dimMap: {[key: string]: DimInfo} = {}; - let nameMap: {[key: string]: ExpandedView} = {}; - - for (let i = 0; i < views.length; ++i) { - const mets = views[i].metrics; - const dims = views[i].dimensions; - for (let j = 0; j < mets.length; ++j) { - if (!nameMap[mets[j].name]) { - nameMap[mets[j].name] = views[i]; - } - - } - - for (let j = 0; j < dims.length; ++j) { - if (!dimMap[dims[j].name]) { - dimMap[dims[j].name] = { - dim: dims[j], - views: [views[i]] - }; - nameMap[dims[j].name] = views[i]; - } - - else { - dimMap[dims[j].name].views.push(views[i]); - } - } - } - - // Projection - const strMetrics = metrics.map((metric) => { - const view = nameMap[metric.name]; - if (view) { - return this.translateMetric(metric, view); - } - - return ""; - }).filter((item) => item !== ""); - - const parsedDimensions = dimensions.map((dimension) => { - let dim = dimension; - while (!nameMap[dim.name]) { - dim = dim.parent; - } - const view = nameMap[dim.name]; - return this.translateDimension(dimension, dim, view); - }); - - let parsedDimMetrics: DimTranslation[] = []; - - for (let i = 0; i < views.length; ++i) { - const dimMets = views[i].dimMetrics.map((item) => { - return this.translateDimMetric(item, views[i]); - }); - - parsedDimMetrics = parsedDimMetrics.concat(dimMets); - } - - const totalDimensions = parsedDimensions.concat(parsedDimMetrics); - - const strDimensions = totalDimensions.map ((item) => item.aliased); - const grouped = totalDimensions.map((item) => item.noalias); - const elements = strMetrics.concat(strDimensions); - - // Joins - let conds: string[] = []; - for (let i of Object.keys(dimMap)) { - let remainViews = dimMap[i].views.slice(); - let dim = dimMap[i].dim; - let leftSide = this.buildColumn(dim, remainViews.shift().id); - if (remainViews.length > 0) { - while (remainViews.length > 0) { - const id = remainViews.shift().id; - const rightSide = this.buildColumn(dim, id); - conds.push(leftSide + " = " + rightSide); - } - } - - } - - // Selection - let covered: Clause[] = []; - for (let i = 0; i < views.length; ++i) { - // Get the clauses that children already cover - covered = covered.concat(views[i].clauses); - } - - const toCover = clauses.filter((item) => !covered.some ((clause) => { - return clause.id === item.id; - })); - - toCover.forEach((item) => { - const clause = "(" + this.translateClause(item, nameMap) + ")"; - if (clause !== "()") { - conds.push(clause); - } - }); - - // Assembly - - const projection = "SELECT " + elements.join(","); - const source = " FROM " + views.map((view) => view.from).join(","); - const selection = (conds.length > 0) ? " WHERE " + conds.join(" AND ") : ""; - let grouping = ""; - if (grouped.length > 0) { - grouping = " GROUP BY " + grouped.join(","); - } - - return projection + source + selection + grouping; - - } - - private getAggregateFunction(aggrType: AggregationType, origin: boolean): string { - switch (aggrType) { - case AggregationType.SUM: - return "SUM"; - case AggregationType.AVG: - return "AVG"; - case AggregationType.COUNT: - return (origin) ? "COUNT" : "SUM"; - case AggregationType.MAX: - return "MAX"; - case AggregationType.MIN: - return "MIN"; - default: - return ""; - } - - } - - private translateRelation(relation: RelationType, arg: string): string { - switch (relation) { - case RelationType.DAY: - return this.applyRelation("EXTRACT", ["DAY FROM "], [arg]); - case RelationType.MONTH: - return this.applyRelation("EXTRACT", ["MONTH FROM "], [arg]); - case RelationType.YEAR: - return this.applyRelation("EXTRACT", ["YEAR FROM "], [arg]); - case RelationType.DAYOFWEEK: - return this.applyRelation("EXTRACT", ["DOW FROM "], [arg]); + protected typeCast(quotedValue: string, dt: string): string { + switch (dt) { + case "date": + return quotedValue + "::DATE"; + case "integer": + return quotedValue + "::INTEGER"; + case "boolean": + return quotedValue + "::BOOLEAN"; default: - return ""; + return quotedValue; } - - } - - private applyRelation(name: string, args: string[], values: string[]): string { - /* - This adapter uses the concept of functions in Postgres to - implement BLENDB sub-dimention relations, this functions - applys the transformation to build the call of a Postgres - funtion. Note that this function can be native from postgres, - like EXTRACT, or even implemented on the database. - This function is short and only used in the translateRelation - method however is a bit complex and is possible to be used - several times, because of that is puted appart to make easyer update - and avoid problems - Example - applyRelation ("EXTRACT", "["DAY FROM"]", ["view_0.date"]) - output: EXTRACT(DAY FROM view_0.date) - */ - return name + "(" + args.map((item, idx) => item + values[idx]).join(",") + ")"; } - private buildColumn (item: Metric|Dimension, id: string): string { - const quotedName = "\"" + item.name + "\""; - return "view_" + id + "." + quotedName; - } - - private translateClause(clause: Clause, map: {[key: string]: ExpandedView}): string { - const r = clause.filters.map((item) => { - return this.translateFilter(item, map); - }).filter((item) => { - return item !== ""; - }); - return r.join(" OR "); - } - - private translateFilter(filter: Filter, map: {[key: string]: ExpandedView}): string { - if (!map[filter.target.name]) { - return ""; - } - - const viewId = map[filter.target.name].id; - const leftSide = this.buildColumn(filter.target, viewId); - const op = this.translateOperator(filter.operator); - const dataType = this.translateDataType(filter.target.dataType); - const quotedValue = "'" + filter.value + "'"; - return leftSide + op + quotedValue + dataType; - } - - private translateMetric(metric: Metric, view: ExpandedView): string { - const func = this.getAggregateFunction(metric.aggregation, view.origin); - const quotedName = "\"" + metric.name + "\""; - const extMetric = func + "(" + this.buildColumn(metric, view.id) + ")"; - return extMetric + " AS " + quotedName; - } - - private translateDimMetric(metric: Metric, view: ExpandedView): DimTranslation { - const quotedName = "\"" + metric.name + "\""; - const extMetric = this.buildColumn(metric, view.id); - return { aliased: extMetric + " AS " + quotedName, noalias: extMetric }; - } - - private translateDimension(dimension: Dimension, - ancestor: Dimension, - view: ExpandedView): DimTranslation { - const quotedName = "\"" + dimension.name + "\""; - let extDimension = this.buildColumn(ancestor, view.id); - let aux = dimension; - while (aux.name !== ancestor.name) { - extDimension = this.translateRelation(aux.relation, extDimension); - aux = aux.parent; - } - return { aliased: extDimension + " AS " + quotedName, noalias: extDimension }; - } - - private translateOperator(op: FilterOperator): string { + protected applyOperator(lSide: string, rSide: string, op: FilterOperator): string { switch (op) { case FilterOperator.EQUAL: - return " = "; + return lSide + " = " + rSide; case FilterOperator.NOTEQUAL: - return " != "; + return lSide + " != " + rSide; case FilterOperator.GREATER: - return " > "; + return lSide + " > " + rSide; case FilterOperator.LOWER: - return " < "; + return lSide + " < " + rSide; case FilterOperator.GREATEREQ: - return " >= "; + return lSide + " >= " + rSide; case FilterOperator.LOWEREQ: - return " <= "; + return lSide + " <= " + rSide; default: return ""; } - - } - - private translateDataType(dt: string ): string { - switch (dt) { - case "date": - return "::DATE"; - case "integer": - return "::INTEGER"; - case "boolean": - return "::BOOLEAN"; - default: - return ""; - } - } - - private compareKeys(a: Dimension[], b: Dimension[], blackList: string[]): number { - /* - SUPER WARNING: WHEN THE BLACK LIST IS USED THE VIEW IS - UNMATERIALIZEBLE, BUT THE QUERY CAN AGGREGATE THE VALUES - */ - let c = a.filter((i) => !blackList.some((bad) => bad === i.name)); - let d = b.filter((i) => !blackList.some((bad) => bad === i.name)); - let length = 0; - let res = c.length - d.length; - if (c.length < d.length) { - length = c.length; - } - else { - length = d.length; - } - - for (let i = 0; i < length; ++i) { - if (c[i].name < d[i].name) { - return -1; - } - - else if (c[i].name > d[i].name) { - return 1; - } - } - - return res; - } - - private similarDimensions(a: Dimension[], b: Dimension[]): number { - let count = 0; - for (let i = 0; i < a.length; ++i) { - if (b.some((itemB) => a[i].name === itemB.name)) { - count++; - } - } - return count; - } - - private removeDuplicatedDimensions(candidateDims: Dimension[]): Dimension[] { - let filterDims: { [key: string]: boolean } = {}; - const dims = []; - for (let i = 0; i < candidateDims.length; ++i) { - if (!filterDims[candidateDims[i].name]) { - dims.push(candidateDims[i]); - filterDims[candidateDims[i].name] = true; - } - } - return dims; } } diff --git a/src/adapter/sql.ts b/src/adapter/sql.ts new file mode 100644 index 0000000000000000000000000000000000000000..244863a3d75ed938e69d3250402bcdb4347fdb41 --- /dev/null +++ b/src/adapter/sql.ts @@ -0,0 +1,840 @@ +/* + * Copyright (C) 2016 Centro de Computacao Cientifica e Software Livre + * Departamento de Informatica - Universidade Federal do Parana + * + * This file is part of blend. + * + * blend is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * blend is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with blend. If not, see <http://www.gnu.org/licenses/>. + */ + +import { Adapter } from "../core/adapter"; +import { Metric } from "../core/metric"; +import { Dimension } from "../core/dimension"; +import { Clause } from "../core/clause"; +import { Filter, FilterOperator } from "../core/filter"; +import { AggregationType, RelationType } from "../common/types"; +import { View } from "../core/view"; + +interface ExpandedView { + dimensions: Dimension[]; + metrics: Metric[]; + dimMetrics: Metric[]; + keys: Dimension[]; + clauses: Clause[]; + from: string; + id: string; + origin: boolean; +} + +interface DimInfo { + dim: Dimension; + views: ExpandedView[]; +} + +interface DimTranslation { + aliased: string; + noalias: string; + alias: string; + expanded: boolean; +} + +export abstract class SQLAdapter extends Adapter { + public getQueryFromView(view: View): string { + /* + Find the base (materialized) views that has this data and exapand + than (make a parse to the format used in the adapter) + */ + const materialized = this.searchMaterializedViews(view).sort((a, b) => { + return (a.id < b.id) ? -1 : 1; + }).map((item) => { + return { + id: item.id, + from: "view_" + item.id, + dimMetrics: [], + metrics: item.metrics.filter((i) => { + return view.metrics.some((j) => i.name === j.name); + }), + dimensions: item.dimensions, + keys: item.keys, + clauses: item.clauses, + origin: item.origin + }; + }); + + // Remove repeated views from the result + let partialJoin = [materialized[0]]; + for (let i = 1; i < materialized.length; ++i) { + if (materialized[i - 1].id !== materialized[i].id) { + partialJoin.push(materialized[i]); + } + } + + const blackList = view.dimensions.map((i) => i.name); + for (let i = 0; i < view.clauses.length; ++i) { + if (view.clauses[i].filters.length === 1) { + let filter = view.clauses[i].filters[0]; + if (filter.operator === FilterOperator.EQUAL) { + blackList.push(filter.target.name); + } + } + } + + /* + If there is more than one source of data (tables/views) + a join is needed. + + Partial Join represents how many sources still exists, + every join reduces this number. + */ + let clausesToCover = view.clauses.map((i) => i); + while (partialJoin.length > 1) { + /* + Variable map finds what dimenensions are still needed to + complete this query, they are required for 2 reasons. + 1 - To make joins + 2 - Because they are in the query + + For each view that has this dimension we add one score to + this dimension, if they are in the query the same. + + Automatically if the dimension is in the query there will be + at least one view with this atribute (or the query could not be + completed) so dimensions in the query always have score of + at least 2. + + To make a join the dimension must be in 2 different views, + creating a score of 2 either. + + If the score is less than 2 so this dimension is not required + anymore and can be removed. + */ + let map: { [key: string]: number } = {}; + let partialsChange = false; + for (let i = 0; i < partialJoin.length; ++i) { + const dims = partialJoin[i].dimensions; + for (let k = 0; k < dims.length; ++k) { + if (!map[dims[k].name]) { + map[dims[k].name] = 1; + } + + else { + ++map[dims[k].name]; + } + } + } + + for (let i = 0; i < view.dimensions.length; ++i) { + let dim = view.dimensions[i]; + while (dim !== null) { + if (map[dim.name]) { + ++map[dim.name]; + } + dim = dim.parent; + } + } + + /* + Also mark scores for dimensions inside clauses + */ + for (let i = 0; i < clausesToCover.length; ++i) { + for (let j = 0; j < clausesToCover[i].targets.length; ++j) { + if (map[clausesToCover[i].targets[j].name]) { + ++map[clausesToCover[i].targets[j].name]; + } + } + } + + for (let i = 0; i < partialJoin.length; ++i) { + const dims = partialJoin[i].dimensions.filter((item) => { + return map[item.name] > 1; + }); + const keys = partialJoin[i].keys.filter((item) => { + return map[item.name] > 1; + }); + /* + At this point the dimensions with less than score 2 + are removed, if this happens the view is agreggated + again, with less dimensions, removing this dimension + from the view. + */ + + let coveredClauses: Clause[] = []; + let notCoveredClauses: Clause[] = []; + /* + If all dimensions in a clause are a sub set of the + dimensions of a view, this clause is apllied now, + propagating the clause to this point. + + Then this clause is removed from the set of clauses + */ + for (let j = 0; j < clausesToCover.length; ++j) { + if (clausesToCover[j].isCovered(partialJoin[i].dimensions)) { + coveredClauses.push(clausesToCover[j]); + } + else { + notCoveredClauses.push(clausesToCover[j]); + } + } + clausesToCover = notCoveredClauses.filter((clause) => { + return !partialJoin[i].clauses.some((c) => c.id === clause.id); + }); + if (dims.length < partialJoin[i].dimensions.length || coveredClauses.length > 0) { + const partial = new View({ + metrics: partialJoin[i].metrics, + dimensions: dims, + keys: keys, + origin: false, + clauses: coveredClauses.concat(partialJoin[i].clauses), + materialized: false + }); + const from = "(" + + this.buildQuery(partial, [partialJoin[i]]) + + ") AS view_" + partial.id + "\n"; + + partialJoin[i].id = partial.id; + partialJoin[i].dimensions = partial.dimensions; + partialJoin[i].keys = partial.keys; + partialJoin[i].origin = partial.origin; + partialJoin[i].from = from; + + partialsChange = true; + } + } + /* + If at least one of the views changed (have the number of + dimensions reduced) returns to the begining of the loop + again. + + Othewise we need to make a join. + */ + if (!partialsChange) { + /* + Sorting the views by keys. + If the keys are identical, then they + will be in sequence, and views with identical + keys can be joined. + + Sort an array of keys is the same as sort a + array of strings. + */ + const sorted = partialJoin.sort((a, b) => { + return this.compareKeys(a.keys, b.keys, blackList); + }); + /* + SUPER WARNING: WHEN THE BLACK LIST IS USED THE VIEW IS + UNMATERIALIZEBLE, BUT THE QUERY CAN AGGREGATE THE VALUES + + The blackList is the array of dimensions of the query plus + the dimensions in filters using the equality operator. + In further coments is expained that the relation to make + a join must be one-to-one between the tables. + However and a dimension is choosed, a sub view is + created and if the relation is preserved in the sub view + the query can be agregated, but this view cannot be re-used + so it is unmaterializeble. + + The equality operator is the same as select one subview. + */ + /* + First of all, the remaining views are splited in segments. + + A segment contains views with the same keys that are great + to make joins. Joins like this do not create "dimensional + metrics". + + In joins like this one row of each view will be connected + with at most one row of each other table. + */ + const segment = [[sorted[0]]]; + let segmentId = 0; + for (let i = 1; i < sorted.length; ++i) { + if (this.compareKeys(sorted[i - 1].keys, sorted[i].keys, blackList) === 0) { + segment[segmentId].push(sorted[i]); + } + else { + ++segmentId; + segment.push([sorted[i]]); + } + } + + partialJoin = []; + let ableToJoin = false; + + for (let i = 0; i < segment.length; ++i) { + /* + If a segment has more than one view, a join can be made + */ + if (segment[i].length > 1) { + let mets: Metric[] = []; + let clauses: Clause[] = []; + let dims: Dimension[] = []; + let dimMetrics: Metric[] = []; + for (let j = 0; j < segment[i].length; ++j) { + mets = mets.concat(segment[i][j].metrics); + clauses = clauses.concat(segment[i][j].clauses); + dims = dims.concat(segment[i][j].dimensions); + dimMetrics = dimMetrics.concat(segment[i][j].dimMetrics); + } + + dims = this.removeDuplicatedDimensions(dims); + /* + Its atributes are just concatenated and the + duplicates removed. + */ + + const partial = new View({ + metrics: mets, + dimensions: dims, + keys: segment[i][0].keys, + origin: false, + clauses: clauses, + materialized: false + }); + const viewFrom = "(" + + this.buildQuery(partial, segment[i]) + + ") AS view_" + partial.id + "\n"; + + partialJoin.push({ + id: partial.id, + from: viewFrom, + dimMetrics: dimMetrics, + metrics: partial.metrics, + dimensions: partial.dimensions, + keys: partial.keys, + clauses: partial.clauses, + origin: partial.origin + }); + + ableToJoin = true; + } + + else { + /* + If the segment has just one view, anything can be + done at this point, so just reinsert this view in + set of views. + */ + partialJoin.push(segment[i][0]); + } + } + + /* + If at least one join was made in the last part (a segment + with more than one view) than return to the begining of the + loop. + + This permits after a join remove the dimensions that were + only choosen to this join, and are no longer required + + Ideally the joins should be restrict the join method used + above, but in some cases this can not be done. + + So if all the segments have only one view inside, move + to the next method. + */ + if (!ableToJoin) { + /* + At this point 2 views will be joined, first the + similarity with each pair of views is calculated, + the pair with the biggedt similarity will be joined. + + Similarity is calculated with the number of common + dimensions in the keys. + */ + let similarity = 0; + let idx0 = 0; + let idx1 = 1; + for (let i = 0; i < partialJoin.length; ++i) { + for (let j = i + 1 ; j < partialJoin.length; ++j) { + const pi = partialJoin[i].keys; + const pj = partialJoin[j].keys; + let score = this.similarDimensions (pi, pj); + if (similarity < score) { + similarity = score; + idx0 = i; + idx1 = j; + } + } + } + + const partial0 = partialJoin[idx0]; + const partial1 = partialJoin[idx1]; + + partialJoin.splice(idx1, 1); + partialJoin.splice(idx0, 1); + + /* + Once the views are select they are joined with the + same method, concatenedted its atributes and + removing duplicates, however the nasty effect of + this join is the creation of "dimensional metrics". + + "Dimensional metrics" are metrics that can no longer + be aggregated, and at this point to the end + of a query they will act as dimensions. + + This change happens to avoid inconsistency generated + by a join where one row of one table can be connected + to more than one of other table. + + Take this example. + + View0 : metrics [met0], dimensions [dim0] + values: [{met0: 10, dim0: 1}] + View1 : metrics [met1], dimensions [dim2] + values: [{met1: 10, dim2: 1}. {met1: 5, dim2: 2}] + View2 : metrics [], dimensions [dim0, dim1, dim2] + values: [ + {dim0: 1, dim1: 1, dim2: 1}, + {dim0: 1, dim1: 1, dim2: 2} + ] + The query is metrics [met0, met1] and dimensions [dim1] + First a join of View0 and View1 is made, the result + is: [ + {dim0: 1, dim1: 1, dim2: 1, met0: 10}, + {dim0: 1, dim1: 1, dim2: 2, met0: 10} + ] + Note that the value of met0 is duplicated. + Now dim0 is removed, than joined with view2 resulting + in: [ + {met1: 10, dim1: 1, dim2: 1, met0: 10}, + {met1: 5 , dim1: 1, dim2: 2, met0: 10} + ] + + Lets assume that the agregation is SUM + If we remove dim2 and re-agregate the result is: [ + {met1: 15, dim1: 1, met0: 20} + ] + + This result is wrong. The replication of the value + met0 affects the result. + + See if met1 was not required, first the dimemnsion would + be reduced, left dim0 and dim1, than joined that reduced + again resulting in the value [ + {dim1:1, met0: 10} + ] + + Is this case there is no duplication and the aggregation + does not include more rows than should. + + To solve this problem the met0 must become a dimension, + in other words, not aggregated again. If the met0 was + not agregated in the query met0, met1, dim1 the result + is: [ + {met1: 15, dim1: 1, met0: 10} + ] + what is compatible. + + After this extreme long explanation what must be + known is: Joining views with diferent keys + generate "dimensional metrics". + + Views with "dimensional metrics" can not used for future + queries because can not be re-agregated, so this must be + avoided and is one-query only views. + */ + + let dimMetrics: Metric[]; + let mets: Metric[]; + let dims = partial0.dimensions.concat(partial1.dimensions); + dims = this.removeDuplicatedDimensions(dims); + let keys = partial0.keys.concat(partial1.keys); + keys = this.removeDuplicatedDimensions(keys); + if (partial0.keys.length === similarity) { + /* + Here the metrics become dimensions, but the effect + can be reduced. If the keys of partial0 + is a sub set of the keys ou partial1 + than the number of rows of partial 1 is not + affected, in other words the metrics of partial1 + can be aggregated and does not need to become + dimensions. + */ + partial0.dimMetrics = partial0.dimMetrics.concat(partial0.metrics); + partial0.metrics = []; + mets = partial1.metrics; + } + + else if (partial1.keys.length === similarity) { + /* + The same occurs if the keys of partia1 is a subset + of partial0. + */ + partial1.dimMetrics = partial1.dimMetrics.concat(partial1.metrics); + partial1.metrics = []; + mets = partial0.metrics; + } + + else { + /* + But if there is no sub set, than both sides have + the metrics turned in dimensions. + */ + partial0.dimMetrics = partial0.dimMetrics.concat(partial0.metrics); + partial0.metrics = []; + partial1.dimMetrics = partial1.dimMetrics.concat(partial1.metrics); + partial1.metrics = []; + mets = []; + } + + dimMetrics = partial0.dimMetrics.concat(partial1.dimMetrics); + const partial = new View({ + metrics: mets, + dimensions: dims, + keys: keys, + origin: false, + clauses: partial0.clauses.concat(partial1.clauses), + materialized: false + }); + const id = new View({ + metrics: mets.concat(dimMetrics), + dimensions: dims, + keys: keys, + origin: false, + clauses: partial0.clauses.concat(partial1.clauses), + materialized: false + }).id; + const viewFrom = "(" + + this.buildQuery(partial, [partial0, partial1]) + + ") AS view_" + id + "\n"; + partialJoin.push({ + id: id, + from: viewFrom, + dimMetrics: dimMetrics, + metrics: mets, + dimensions: dims, + keys: keys, + clauses: partial.clauses, + origin: false + }); + + } + } + } + + /* + When only one view remain, the query is made and a ; + is added at the end. + + TODO: Probrably this last line adds one more + layer to the query, that is in fact unnecessary. + Think a way to remove-it. + */ + return this.buildQuery(view, partialJoin) + ";"; + } + + private searchMaterializedViews(view: View): View[] { + let r: View[] = []; + if (view.materialized) { + return [view]; + } + + else { + let children = view.childViews; + for (let i = 0; i < children.length; ++i) { + r = r.concat(this.searchMaterializedViews(children[i])); + } + } + + return r; + } + + private buildQuery(target: View, views: ExpandedView[]) { + const metrics = target.metrics; + const dimensions = target.dimensions; + const clauses = target.clauses; + + let dimMap: {[key: string]: DimInfo} = {}; + let nameMap: {[key: string]: ExpandedView} = {}; + + for (let i = 0; i < views.length; ++i) { + const mets = views[i].metrics; + const dims = views[i].dimensions; + for (let j = 0; j < mets.length; ++j) { + if (!nameMap[mets[j].name]) { + nameMap[mets[j].name] = views[i]; + } + + } + + for (let j = 0; j < dims.length; ++j) { + if (!dimMap[dims[j].name]) { + dimMap[dims[j].name] = { + dim: dims[j], + views: [views[i]] + }; + nameMap[dims[j].name] = views[i]; + } + + else { + dimMap[dims[j].name].views.push(views[i]); + } + } + } + + // Projection + const strMetrics = metrics.map((metric) => { + const view = nameMap[metric.name]; + if (view) { + return this.translateMetric(metric, view); + } + + return ""; + }).filter((item) => item !== ""); + + const parsedDimensions = dimensions.map((dimension) => { + let dim = dimension; + while (!nameMap[dim.name]) { + dim = dim.parent; + } + const view = nameMap[dim.name]; + return this.translateDimension(dimension, dim, view); + }); + + let parsedDimMetrics: DimTranslation[] = []; + + for (let i = 0; i < views.length; ++i) { + const dimMets = views[i].dimMetrics.map((item) => { + return this.translateDimMetric(item, views[i]); + }); + + parsedDimMetrics = parsedDimMetrics.concat(dimMets); + } + + const totalDimensions = parsedDimensions.concat(parsedDimMetrics); + + const strDimensions = totalDimensions.map ((item) => item.aliased); + const grouped = totalDimensions.map((item) => { + return (item.expanded) ? item.alias : item.noalias; + }); + const elements = strMetrics.concat(strDimensions); + + // Joins + let conds: string[] = []; + for (let i of Object.keys(dimMap)) { + let remainViews = dimMap[i].views.slice(); + let dim = dimMap[i].dim; + let leftSide = this.buildColumn(dim, remainViews.shift().id); + if (remainViews.length > 0) { + while (remainViews.length > 0) { + const id = remainViews.shift().id; + const rightSide = this.buildColumn(dim, id); + conds.push(leftSide + " = " + rightSide); + } + } + + } + + // Selection + let covered: Clause[] = []; + for (let i = 0; i < views.length; ++i) { + // Get the clauses that children already cover + covered = covered.concat(views[i].clauses); + } + + const toCover = clauses.filter((item) => !covered.some ((clause) => { + return clause.id === item.id; + })); + + toCover.forEach((item) => { + const clause = "(" + this.translateClause(item, nameMap) + ")"; + if (clause !== "()") { + conds.push(clause); + } + }); + + // Assembly + + const projection = "SELECT " + elements.join(","); + const source = " FROM " + views.map((view) => view.from).join(","); + const selection = (conds.length > 0) ? " WHERE " + conds.join(" AND ") : ""; + let grouping = ""; + if (grouped.length > 0) { + grouping = " GROUP BY " + grouped.join(","); + } + + return projection + source + selection + grouping; + + } + + private getAggregateFunction(aggrType: AggregationType, origin: boolean): string { + switch (aggrType) { + case AggregationType.SUM: + return "SUM"; + case AggregationType.AVG: + return "AVG"; + case AggregationType.COUNT: + return (origin) ? "COUNT" : "SUM"; + case AggregationType.MAX: + return "MAX"; + case AggregationType.MIN: + return "MIN"; + default: + return ""; + } + + } + + private translateRelation(relation: RelationType, arg: string): string { + switch (relation) { + case RelationType.DAY: + return this.applyRelation("EXTRACT", ["DAY FROM "], [arg]); + case RelationType.MONTH: + return this.applyRelation("EXTRACT", ["MONTH FROM "], [arg]); + case RelationType.YEAR: + return this.applyRelation("EXTRACT", ["YEAR FROM "], [arg]); + default: + return ""; + } + + } + + private applyRelation(name: string, args: string[], values: string[]): string { + /* + This adapter uses the concept of functions in Postgres to + implement BLENDB sub-dimention relations, this functions + applys the transformation to build the call of a Postgres + funtion. Note that this function can be native from postgres, + like EXTRACT, or even implemented on the database. + This function is short and only used in the translateRelation + method however is a bit complex and is possible to be used + several times, because of that is puted appart to make easyer update + and avoid problems + Example + applyRelation ("EXTRACT", "["DAY FROM"]", ["view_0.date"]) + output: EXTRACT(DAY FROM view_0.date) + */ + return name + "(" + args.map((item, idx) => item + values[idx]).join(",") + ")"; + } + + private buildColumn (item: Metric|Dimension, id: string): string { + const quotedName = "\"" + item.name + "\""; + return "view_" + id + "." + quotedName; + } + + private translateClause(clause: Clause, map: {[key: string]: ExpandedView}): string { + const r = clause.filters.map((item) => { + return this.translateFilter(item, map); + }).filter((item) => { + return item !== ""; + }); + return r.join(" OR "); + } + + private translateFilter(filter: Filter, map: {[key: string]: ExpandedView}): string { + if (!map[filter.target.name]) { + return ""; + } + + const viewId = map[filter.target.name].id; + const leftSide = this.buildColumn(filter.target, viewId); + const quotedValue = "'" + filter.value + "'"; + const castedValue = this.typeCast(quotedValue, filter.target.dataType); + return this.applyOperator(leftSide, castedValue, filter.operator); + } + + private translateMetric(metric: Metric, view: ExpandedView): string { + const func = this.getAggregateFunction(metric.aggregation, view.origin); + const quotedName = "\"" + metric.name + "\""; + const extMetric = func + "(" + this.buildColumn(metric, view.id) + ")"; + return extMetric + " AS " + quotedName; + } + + private translateDimMetric(metric: Metric, view: ExpandedView): DimTranslation { + const quotedName = "\"" + metric.name + "\""; + const extMetric = this.buildColumn(metric, view.id); + return { + aliased: extMetric + " AS " + quotedName, + noalias: extMetric, + alias: quotedName, + expanded: false + }; + } + + private translateDimension(dimension: Dimension, + ancestor: Dimension, + view: ExpandedView): DimTranslation { + const quotedName = "\"" + dimension.name + "\""; + let extDimension = this.buildColumn(ancestor, view.id); + let aux = dimension; + let expanded = false; + while (aux.name !== ancestor.name) { + extDimension = this.translateRelation(aux.relation, extDimension); + aux = aux.parent; + expanded = true; + } + return { + aliased: extDimension + " AS " + quotedName, + noalias: extDimension, + alias: quotedName, + expanded: expanded + }; + } + + protected abstract applyOperator(leftSide: string, rightSide: string, op: FilterOperator): string; + + protected abstract typeCast(quotedValue: string, dt: string): string; + + private compareKeys(a: Dimension[], b: Dimension[], blackList: string[]): number { + /* + SUPER WARNING: WHEN THE BLACK LIST IS USED THE VIEW IS + UNMATERIALIZEBLE, BUT THE QUERY CAN AGGREGATE THE VALUES + */ + let c = a.filter((i) => !blackList.some((bad) => bad === i.name)); + let d = b.filter((i) => !blackList.some((bad) => bad === i.name)); + let length = 0; + let res = c.length - d.length; + if (c.length < d.length) { + length = c.length; + } + else { + length = d.length; + } + + for (let i = 0; i < length; ++i) { + if (c[i].name < d[i].name) { + return -1; + } + + else if (c[i].name > d[i].name) { + return 1; + } + } + + return res; + } + + private similarDimensions(a: Dimension[], b: Dimension[]): number { + let count = 0; + for (let i = 0; i < a.length; ++i) { + if (b.some((itemB) => a[i].name === itemB.name)) { + count++; + } + } + return count; + } + + private removeDuplicatedDimensions(candidateDims: Dimension[]): Dimension[] { + let filterDims: { [key: string]: boolean } = {}; + const dims = []; + for (let i = 0; i < candidateDims.length; ++i) { + if (!filterDims[candidateDims[i].name]) { + dims.push(candidateDims[i]); + filterDims[candidateDims[i].name] = true; + } + } + return dims; + } +} diff --git a/src/api/controllers/engine.spec.ts b/src/api/controllers/engine.spec.ts index dc436712347eafbb1f57da91512ac50c7b5e141d..0d1ea0804b331b7c11197cc826df18bb32687410 100644 --- a/src/api/controllers/engine.spec.ts +++ b/src/api/controllers/engine.spec.ts @@ -44,7 +44,7 @@ describe("API engine controller", () => { .expect((res: any) => { let result = res.body; expect(result).to.be.an("array"); - expect(result).to.have.length(13); + expect(result).to.have.length(12); }) .end(done); }); diff --git a/src/api/middlewares/adapter.ts b/src/api/middlewares/adapter.ts index 15e883b086d8aa27e27ff65d8c5ca68dc37d2495..1e4bcbf0725a3ffcc74dd0d94886f898a525f944 100644 --- a/src/api/middlewares/adapter.ts +++ b/src/api/middlewares/adapter.ts @@ -21,6 +21,7 @@ import { Middleware } from "../types"; import { Adapter } from "../../core/adapter"; import { PostgresAdapter } from "../../adapter/postgres"; +import { MonetAdapter, MonetConfig } from "../../adapter/monet"; import { PoolConfig } from "pg"; import { Connection } from "../../util/configParser"; @@ -41,3 +42,19 @@ export function PostgresMw(config: Connection): Middleware { }; } + +export function MonetMw(config: Connection): Middleware { + let parsedConfig: MonetConfig = { + user: config.user, + dbname: config.database, + password: config.password, + host: config.host, + port: config.port, + }; + let adapter: Adapter = new MonetAdapter(parsedConfig); + return function monetMiddleware(req, res, next) { + req.adapter = adapter; + next(); + }; + +} diff --git a/src/common/types.ts b/src/common/types.ts index 661fce354b942243046905bcb9b986662e818f8b..504dcd68eee7fee526931867efcbbbf18778e09b 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -32,5 +32,4 @@ export enum RelationType { DAY, MONTH, YEAR, - DAYOFWEEK }; diff --git a/src/core/dimension.ts b/src/core/dimension.ts index bda4e07fe43ad15b09e71294ebdd8abe4392746c..946bc43fe4cfa826ece50833155fdee8b2a90efc 100644 --- a/src/core/dimension.ts +++ b/src/core/dimension.ts @@ -79,8 +79,6 @@ export class Dimension { return RelationType.MONTH; case "year": return RelationType.YEAR; - case "dayofweek": - return RelationType.DAYOFWEEK; default: return RelationType.NONE; } @@ -94,8 +92,6 @@ export class Dimension { return "month"; case RelationType.YEAR: return "year"; - case RelationType.DAYOFWEEK: - return "dayofweek"; default: return ""; } diff --git a/src/core/engine.spec.ts b/src/core/engine.spec.ts index 66c5c1b52ab7dd7914ad54601bad04d6ade24009..b326b88303bef93c1223563f4a604ff51a9f9542 100644 --- a/src/core/engine.spec.ts +++ b/src/core/engine.spec.ts @@ -55,7 +55,7 @@ describe("engine class", () => { expect(optimalView.dimensions).to.be.an("array"); expect(optimalView.childViews).to.be.an("array"); expect(optimalView.metrics).to.have.length(12); - expect(optimalView.dimensions).to.have.length(13); + expect(optimalView.dimensions).to.have.length(12); }); it("should throw an exception, query with non-existent metric", () => { let error: boolean = false; diff --git a/src/main.ts b/src/main.ts index 6214b70217712560a5e582846998998eea9cc8d9..56a5acf4d25df63d151d246a242df35ee023eebe 100755 --- a/src/main.ts +++ b/src/main.ts @@ -38,7 +38,7 @@ const config = ConfigParser.parse(configPath); // include middlewares import { EngineMw } from "./api/middlewares/engine"; -import { PostgresMw } from "./api/middlewares/adapter"; +import { PostgresMw, MonetMw } from "./api/middlewares/adapter"; import { ErrorMw } from "./api/middlewares/error"; app.use(EngineMw(config)); @@ -46,8 +46,12 @@ if (config.adapter === "postgres") { app.use(PostgresMw(config.connection)); } +else if (config.adapter === "monet") { + app.use(MonetMw(config.connection)); +} + else { - console.error("Invalid adapter. Options available are: postgres"); + console.error("Invalid adapter. Options available are: postgres and monet"); process.exit(1); } diff --git a/src/util/configParser.spec.ts b/src/util/configParser.spec.ts index 973bad91619ac6d3f99fe340d36a7c598e1b072a..389c6067fff69a2320d2efb0c072fb6e24ba339a 100644 --- a/src/util/configParser.spec.ts +++ b/src/util/configParser.spec.ts @@ -32,8 +32,6 @@ function strToRelationType (str: string): RelationType { return RelationType.MONTH; case "year": return RelationType.YEAR; - case "dayofweek": - return RelationType.DAYOFWEEK; default: return RelationType.NONE; } @@ -181,18 +179,12 @@ describe("configParser utility library", () => { parent: "dim:0", relation: "year" }, - { - name: "dim:dayofweek", - dataType: "integer", - parent: "dim:0", - relation: "dayofweek" - }, { name: "dim:none", dataType: "integer", parent: "dim:0", relation: "none" - }, + } ]; let dims: Dimension[] = [ diff --git a/test/monet/fixture.ts b/test/monet/fixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c16edc929e494b53eff2ed4a22e1bf42e58d021 --- /dev/null +++ b/test/monet/fixture.ts @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2017 Centro de Computacao Cientifica e Software Livre + * Departamento de Informatica - Universidade Federal do Parana + * + * This file is part of blend. + * + * blend is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * blend is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with blend. If not, see <http://www.gnu.org/licenses/>. + */ + +const MDB = require("monetdb")(); +import { View, LoadView } from "../../src/core/view"; +import { each, series } from "async"; +import { Connection } from "../../src/util/configParser"; +import * as fs from "fs"; + +interface TransSet { + init: string; + data: string[]; +} + +export interface Schema { + alias?: string; + query?: string; + data?: string; + fields: any[]; +} + +export class Fixture { + private database: string; + private config: Connection; + + constructor(config: Connection) { + this.config = config; + this.database = config.database; + } + + public load(schemas: LoadView[], create: boolean, cb: (err: Error) => void) { + let client = new MDB({ + user: this.config.user, + dbname: this.config.database, + password: this.config.password, + host: this.config.host, + port: this.config.port, + }); + + /* + Loading data has 2 steps: + 1 - Create a table or truncate a existing one. + Create or truncate dependes from a parameter in the configure + file, this parameter reaches this function as the create + parameter. + 2 - Insert data. + */ + let init: string[] = []; + let data: string[] = []; + for (let i = 0; i < schemas.length; ++i) { + let table: TransSet = this.createTransSet(schemas[i].view, schemas[i].data, create); + init.push(table.init); + data = data.concat(table.data); + } + + client.connect(); + /* + Tables must be creates before data could be inserted, so the + queries that create and insert are splited in 2 arrays. + To garantee that tables exists before start insert data, series + is used. Inside series each query is executed, using each; + */ + series([(callback: (err: Error) => void) => { + each(init, (query, cback) => { + return client.query(query).then((result: any) => cback()) + .fail((err: Error) => (cback(err))); + }, (errQuery: Error) => callback(errQuery)); + }, (callback: (err: Error) => void) => { + each(data, (query, cback) => { + return client.query(query).then((result: any) => cback()) + .fail((err: Error) => (cback(err))); + }, (errQuery: Error) => callback(errQuery)); + }], (errQuery: Error) => { + client.close(); + cb(errQuery); + }); + } + + private typeConvertion(t: string) { + switch (t) { + case "integer": + return "INTEGER"; + case "date": + return "TIMESTAMP"; + case "string": + return "TEXT"; + case "boolean": + return "BOOLEAN"; + default: + return ""; + } + } + + private createTransSet(view: View, filePath: string, create: boolean) { + let props = []; + + for (let i = 0; i < view.metrics.length; ++i) { + let met = view.metrics[i]; + props.push("\"" + met.name + "\" " + this.typeConvertion(met.dataType)); + } + + for (let i = 0; i < view.dimensions.length; ++i) { + let dim = view.dimensions[i]; + props.push("\"" + dim.name + "\" " + this.typeConvertion(dim.dataType)); + } + + let name = "view_" + view.id; + let transaction: TransSet = {init: "", data: []}; + if (create) { + transaction.init = "CREATE TABLE " + name + "(" + props.join(", ") + ")"; + } + else { + transaction.init = "DELETE FROM " + name; + } + + transaction.data = []; + let rows = JSON.parse(fs.readFileSync(filePath, {encoding : "utf8"})); + for (let i = 0; i < rows.length; ++i) { + let values = []; + let keys = []; + for (let key of Object.keys(rows[i])) { + keys.push("\"" + key + "\""); + values.push("'" + this.boolCast(rows[i][key]) + "'"); + } + transaction.data.push("INSERT INTO " + name + + "(" + keys.join(", ") + ") " + + "VALUES (" + values.join(", ") + ")"); + } + return transaction; + } + + private boolCast(text: string): string { + if (text === "t") { + return "true"; + } + + else if (text === "f") { + return "false"; + } + + else { + return text; + } + } +} diff --git a/test/monet/fixtures b/test/monet/fixtures new file mode 120000 index 0000000000000000000000000000000000000000..410001b097c384463e158a7a38376382c18a0e41 --- /dev/null +++ b/test/monet/fixtures @@ -0,0 +1 @@ +../postgres/fixtures/ \ No newline at end of file diff --git a/test/scenario.ts b/test/scenario.ts index 1fb2fac343ffd0ecc5596a7466ccfab1aa3ee0a1..9abe77b2021ce0a3ce233a75e5650452f29baca7 100644 --- a/test/scenario.ts +++ b/test/scenario.ts @@ -212,13 +212,7 @@ const dateSubDim = [ dataType: "integer", parent: dims[0], relation: RelationType.YEAR - }), - new Dimension ({ - name: "dim:0:dow", - dataType: "integer", - parent: dims[0], - relation: RelationType.DAYOFWEEK - }), + }) ]; const dateView = new View({