terrier-engine 4.7.0 → 4.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data-dive/queries/columns.ts +19 -1
- package/data-dive/queries/filters.ts +31 -7
- package/data-dive/queries/queries.ts +34 -4
- package/data-dive/queries/query-editor.ts +12 -5
- package/data-dive/queries/tables.ts +14 -5
- package/data-dive/queries/validation.ts +73 -0
- package/package.json +1 -1
- package/terrier/theme.ts +1 -1
|
@@ -11,6 +11,7 @@ import {Dropdown} from "../../terrier/dropdowns"
|
|
|
11
11
|
import DiveEditor from "../dives/dive-editor"
|
|
12
12
|
import Messages from "tuff-core/messages"
|
|
13
13
|
import Arrays from "tuff-core/arrays"
|
|
14
|
+
import {ColumnValidationError} from "./validation"
|
|
14
15
|
|
|
15
16
|
const log = new Logger("Columns")
|
|
16
17
|
|
|
@@ -57,9 +58,25 @@ export type ColumnRef = {
|
|
|
57
58
|
alias?: string
|
|
58
59
|
grouped?: boolean
|
|
59
60
|
function?: AggFunction | DateFunction
|
|
61
|
+
errors?: ColumnValidationError[]
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Computes the name of the resulting select clause.
|
|
67
|
+
* @param table
|
|
68
|
+
* @param col
|
|
69
|
+
*/
|
|
70
|
+
function computeSelectName(table: TableRef, col: ColumnRef): string {
|
|
71
|
+
if (col.alias?.length) {
|
|
72
|
+
return col.alias
|
|
73
|
+
} else if (table.prefix?.length) {
|
|
74
|
+
return `${table.prefix}${col.name}`
|
|
75
|
+
} else {
|
|
76
|
+
return col.name
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
|
|
64
81
|
////////////////////////////////////////////////////////////////////////////////
|
|
65
82
|
// Rendering
|
|
@@ -405,7 +422,8 @@ class SelectColumnsDropdown extends Dropdown<{modelDef: ModelDef, callback: Sele
|
|
|
405
422
|
|
|
406
423
|
const Columns = {
|
|
407
424
|
render,
|
|
408
|
-
functionType
|
|
425
|
+
functionType,
|
|
426
|
+
computeSelectName
|
|
409
427
|
}
|
|
410
428
|
|
|
411
429
|
export default Columns
|
|
@@ -13,6 +13,7 @@ import Format from "../../terrier/format"
|
|
|
13
13
|
import DiveEditor from "../dives/dive-editor"
|
|
14
14
|
import Messages from "tuff-core/messages"
|
|
15
15
|
import Arrays from "tuff-core/arrays"
|
|
16
|
+
import {SelectOptions} from "tuff-core/forms"
|
|
16
17
|
|
|
17
18
|
const log = new Logger("Filters")
|
|
18
19
|
|
|
@@ -27,8 +28,33 @@ type BaseFilter = {
|
|
|
27
28
|
edit_label?: string
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
export type DirectOperator = typeof
|
|
31
|
+
const directOperators = ['eq', 'ne', 'ilike', 'lt', 'gt', 'lte', 'gte'] as const
|
|
32
|
+
export type DirectOperator = typeof directOperators[number]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Computes the operator options for the given column type.
|
|
36
|
+
* @param type
|
|
37
|
+
*/
|
|
38
|
+
function operatorOptions(type: string): SelectOptions {
|
|
39
|
+
let operators: DirectOperator[] = ['eq', 'ne'] // equality is the only thing we can assume for any type
|
|
40
|
+
switch (type) {
|
|
41
|
+
case 'text':
|
|
42
|
+
case 'string':
|
|
43
|
+
operators = ['eq', 'ne', 'ilike']
|
|
44
|
+
break
|
|
45
|
+
case 'float':
|
|
46
|
+
case 'integer':
|
|
47
|
+
case 'cents':
|
|
48
|
+
operators = ['eq', 'ne', 'lt', 'gt', 'lte', 'gte']
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
return operators.map(op => {
|
|
52
|
+
return {
|
|
53
|
+
value: op,
|
|
54
|
+
title: operatorDisplay(op)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
32
58
|
|
|
33
59
|
export type DirectFilter = BaseFilter & {
|
|
34
60
|
filter_type: 'direct'
|
|
@@ -73,7 +99,7 @@ function operatorDisplay(op: DirectOperator): string {
|
|
|
73
99
|
case 'ne':
|
|
74
100
|
return '≠'
|
|
75
101
|
case 'ilike':
|
|
76
|
-
return '
|
|
102
|
+
return '≈'
|
|
77
103
|
case 'lt':
|
|
78
104
|
return '<'
|
|
79
105
|
case 'lte':
|
|
@@ -369,10 +395,8 @@ class DirectFilterEditor extends FilterEditor<DirectFilter> {
|
|
|
369
395
|
col.div('.tt-readonly-field', {text: this.state.column})
|
|
370
396
|
})
|
|
371
397
|
parent.div('.operator', col => {
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
})
|
|
375
|
-
this.select(col, 'operator', operatorOptions)
|
|
398
|
+
const opts = operatorOptions(this.columnDef?.type || 'text')
|
|
399
|
+
this.select(col, 'operator', opts)
|
|
376
400
|
})
|
|
377
401
|
parent.div('.filter', col => {
|
|
378
402
|
switch (this.state.column_type) {
|
|
@@ -11,6 +11,7 @@ import inflection from "inflection"
|
|
|
11
11
|
import Messages from "tuff-core/messages"
|
|
12
12
|
import Strings from "tuff-core/strings"
|
|
13
13
|
import Arrays from "tuff-core/arrays"
|
|
14
|
+
import {ColumnRef} from "./columns"
|
|
14
15
|
|
|
15
16
|
const log = new Logger("Queries")
|
|
16
17
|
|
|
@@ -28,13 +29,41 @@ export type Query = {
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
////////////////////////////////////////////////////////////////////////////////
|
|
31
|
-
//
|
|
32
|
+
// Utilities
|
|
33
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
34
|
+
|
|
35
|
+
type ColumnFunction = (table: TableRef, col: ColumnRef) => any
|
|
36
|
+
|
|
37
|
+
function eachColumnForTable(table: TableRef, fn: ColumnFunction) {
|
|
38
|
+
if (table.columns) {
|
|
39
|
+
for (const col of table.columns) {
|
|
40
|
+
fn(table, col)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (table.joins) {
|
|
44
|
+
for (const joinedTable of Object.values(table.joins)) {
|
|
45
|
+
eachColumnForTable(joinedTable, fn)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Recursively iterates over all columns in a query.
|
|
52
|
+
* @param query
|
|
53
|
+
*/
|
|
54
|
+
function eachColumn(query: Query, fn: ColumnFunction) {
|
|
55
|
+
eachColumnForTable(query.from, fn)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
60
|
+
// Server-Side Validation
|
|
32
61
|
////////////////////////////////////////////////////////////////////////////////
|
|
33
62
|
|
|
34
63
|
/**
|
|
35
64
|
* Type for the response to a server-side query validation.
|
|
36
65
|
*/
|
|
37
|
-
export type
|
|
66
|
+
export type QueryServerValidation = ApiResponse & {
|
|
38
67
|
query: Query
|
|
39
68
|
sql?: string
|
|
40
69
|
sql_html?: string
|
|
@@ -47,8 +76,8 @@ export type QueryValidation = ApiResponse & {
|
|
|
47
76
|
* Has the server validate the given query and generate SQL for it.
|
|
48
77
|
* @param query
|
|
49
78
|
*/
|
|
50
|
-
async function validate(query: Query): Promise<
|
|
51
|
-
return await api.post<
|
|
79
|
+
async function validate(query: Query): Promise<QueryServerValidation> {
|
|
80
|
+
return await api.post<QueryServerValidation>("/data_dive/validate_query.json", {query})
|
|
52
81
|
}
|
|
53
82
|
|
|
54
83
|
|
|
@@ -258,6 +287,7 @@ export class QueryModelPicker extends TerrierPart<QueryModelPickerState> {
|
|
|
258
287
|
////////////////////////////////////////////////////////////////////////////////
|
|
259
288
|
|
|
260
289
|
const Queries = {
|
|
290
|
+
eachColumn,
|
|
261
291
|
validate,
|
|
262
292
|
preview,
|
|
263
293
|
renderPreview
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {PartTag} from "tuff-core/parts"
|
|
2
|
-
import Queries, {Query, QueryResult,
|
|
2
|
+
import Queries, {Query, QueryResult, QueryServerValidation} from "./queries"
|
|
3
3
|
import Tables, {FromTableView} from "./tables"
|
|
4
4
|
import {Logger} from "tuff-core/logging"
|
|
5
5
|
import QueryForm, {QuerySettings, QuerySettingsColumns} from "./query-form"
|
|
@@ -9,6 +9,7 @@ import Html from "tuff-core/html"
|
|
|
9
9
|
import ContentPart from "../../terrier/parts/content-part"
|
|
10
10
|
import {TabContainerPart} from "../../terrier/tabs"
|
|
11
11
|
import Messages from "tuff-core/messages"
|
|
12
|
+
import Validation, {QueryClientValidation} from "./validation"
|
|
12
13
|
|
|
13
14
|
const log = new Logger("QueryEditor")
|
|
14
15
|
|
|
@@ -17,7 +18,7 @@ const log = new Logger("QueryEditor")
|
|
|
17
18
|
// Keys
|
|
18
19
|
////////////////////////////////////////////////////////////////////////////////
|
|
19
20
|
|
|
20
|
-
const validationKey = Messages.typedKey<
|
|
21
|
+
const validationKey = Messages.typedKey<QueryServerValidation>()
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
////////////////////////////////////////////////////////////////////////////////
|
|
@@ -60,9 +61,9 @@ class SettingsPart extends ContentPart<SubEditorState> {
|
|
|
60
61
|
|
|
61
62
|
class SqlPart extends ContentPart<SubEditorState> {
|
|
62
63
|
|
|
63
|
-
validation?:
|
|
64
|
+
validation?: QueryServerValidation
|
|
64
65
|
|
|
65
|
-
setValidation(validation:
|
|
66
|
+
setValidation(validation: QueryServerValidation) {
|
|
66
67
|
this.validation = validation
|
|
67
68
|
this.dirty()
|
|
68
69
|
}
|
|
@@ -156,11 +157,13 @@ export default class QueryEditor extends ContentPart<QueryEditorState> {
|
|
|
156
157
|
settingsPart!: SettingsPart
|
|
157
158
|
sqlPart!: SqlPart
|
|
158
159
|
previewPart!: PreviewPart
|
|
160
|
+
clientValidation!: QueryClientValidation
|
|
159
161
|
|
|
160
162
|
updatePreviewKey = Messages.untypedKey()
|
|
161
163
|
|
|
162
164
|
async init() {
|
|
163
165
|
const query = this.state.query
|
|
166
|
+
this.clientValidation = Validation.validateQuery(query)
|
|
164
167
|
|
|
165
168
|
log.info("Initializing query editor", query)
|
|
166
169
|
|
|
@@ -180,7 +183,7 @@ export default class QueryEditor extends ContentPart<QueryEditorState> {
|
|
|
180
183
|
this.previewPart = this.tabs.upsertTab({key: 'preview', title: 'Preview', icon: 'glyp-table', classes: ['no-padding'], click: {key: this.updatePreviewKey}},
|
|
181
184
|
PreviewPart, {editor: this, query})
|
|
182
185
|
|
|
183
|
-
this.tableEditor = this.makePart(FromTableView, {schema: this.state.schema, table: this.state.query.from})
|
|
186
|
+
this.tableEditor = this.makePart(FromTableView, {schema: this.state.schema, queryEditor: this, table: this.state.query.from})
|
|
184
187
|
|
|
185
188
|
this.listenMessage(Tables.updatedKey, m => {
|
|
186
189
|
log.info(`Table ${m.data.model} updated`, m.data)
|
|
@@ -219,10 +222,14 @@ export default class QueryEditor extends ContentPart<QueryEditorState> {
|
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
async validate() {
|
|
225
|
+
// server-side validation
|
|
222
226
|
const res = await Queries.validate(this.state.query)
|
|
223
227
|
log.info(`Query validated`, res)
|
|
224
228
|
this.emitMessage(validationKey, res)
|
|
225
229
|
this.sqlPart.setValidation(res)
|
|
230
|
+
|
|
231
|
+
// client-side validation
|
|
232
|
+
this.clientValidation = Validation.validateQuery(this.state.query)
|
|
226
233
|
this.dirty()
|
|
227
234
|
}
|
|
228
235
|
|
|
@@ -11,6 +11,7 @@ import TerrierFormPart from "../../terrier/parts/terrier-form-part"
|
|
|
11
11
|
import DiveEditor from "../dives/dive-editor"
|
|
12
12
|
import Messages from "tuff-core/messages"
|
|
13
13
|
import Arrays from "tuff-core/arrays"
|
|
14
|
+
import QueryEditor from "./query-editor"
|
|
14
15
|
|
|
15
16
|
const log = new Logger("Tables")
|
|
16
17
|
|
|
@@ -48,7 +49,9 @@ const updatedKey = Messages.typedKey<TableRef>()
|
|
|
48
49
|
* Only keep one (the last one traversed) per table/column combination.
|
|
49
50
|
* This means that some filters may clobber others, but I think it will yield
|
|
50
51
|
* the desired result most of the time.
|
|
52
|
+
* @param schema
|
|
51
53
|
* @param table
|
|
54
|
+
* @param filters
|
|
52
55
|
*/
|
|
53
56
|
function computeFilterInputs(schema: SchemaDef, table: TableRef, filters: Record<string, FilterInput>) {
|
|
54
57
|
for (const f of table.filters || []) {
|
|
@@ -67,7 +70,7 @@ function computeFilterInputs(schema: SchemaDef, table: TableRef, filters: Record
|
|
|
67
70
|
// View
|
|
68
71
|
////////////////////////////////////////////////////////////////////////////////
|
|
69
72
|
|
|
70
|
-
export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaDef, table: T }> {
|
|
73
|
+
export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaDef, queryEditor: QueryEditor, table: T }> {
|
|
71
74
|
|
|
72
75
|
schema!: SchemaDef
|
|
73
76
|
table!: T
|
|
@@ -88,7 +91,7 @@ export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaD
|
|
|
88
91
|
this.modelDef = this.schema.models[this.table.model]
|
|
89
92
|
this.tableName = inflection.titleize(inflection.tableize(this.table.model))
|
|
90
93
|
this.displayName = this.tableName
|
|
91
|
-
this.
|
|
94
|
+
this.updateJoinedViews()
|
|
92
95
|
|
|
93
96
|
this.onClick(this.editColumnsKey, _ => {
|
|
94
97
|
log.info(`Edit ${this.displayName} Columns`)
|
|
@@ -155,7 +158,7 @@ export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaD
|
|
|
155
158
|
log.info(`Creating joined table`, newTable)
|
|
156
159
|
this.table.joins ||= {}
|
|
157
160
|
this.table.joins[newTable.belongs_to] = newTable
|
|
158
|
-
this.
|
|
161
|
+
this.updateJoinedViews()
|
|
159
162
|
}
|
|
160
163
|
this.app.showModal(JoinedTableEditorModal, {table, belongsTo, callback, parentTable: this.state.table as TableRef})
|
|
161
164
|
}
|
|
@@ -165,9 +168,9 @@ export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaD
|
|
|
165
168
|
/**
|
|
166
169
|
* Re-generates all views for the joined tables.
|
|
167
170
|
*/
|
|
168
|
-
|
|
171
|
+
updateJoinedViews() {
|
|
169
172
|
const states = Object.values(this.table.joins || {}).map(table => {
|
|
170
|
-
return {schema: this.schema, table}
|
|
173
|
+
return {schema: this.schema, queryEditor: this.state.queryEditor, table}
|
|
171
174
|
})
|
|
172
175
|
this.assignCollection('joined', JoinedTableView, states)
|
|
173
176
|
for (const part of this.getCollectionParts('joined')) {
|
|
@@ -242,6 +245,12 @@ export class TableView<T extends TableRef> extends ContentPart<{ schema: SchemaD
|
|
|
242
245
|
for (const col of this.table.columns) {
|
|
243
246
|
section.div('.column.line', line => {
|
|
244
247
|
Columns.render(line, col)
|
|
248
|
+
if (col.errors?.length) {
|
|
249
|
+
line.class('error')
|
|
250
|
+
for (const error of col.errors) {
|
|
251
|
+
line.div('.error-message').text(error.message)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
245
254
|
})
|
|
246
255
|
}
|
|
247
256
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Queries, {Query} from "./queries"
|
|
2
|
+
import Columns, {ColumnRef} from "./columns"
|
|
3
|
+
|
|
4
|
+
export type ColumnValidationError = {
|
|
5
|
+
message: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type QueryClientValidation = {
|
|
9
|
+
columns: ColumnValidationError[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
13
|
+
// Client-Side Validation
|
|
14
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validates that a query will produce valid SQL.
|
|
18
|
+
*/
|
|
19
|
+
function validateQuery(query: Query): QueryClientValidation {
|
|
20
|
+
|
|
21
|
+
const validation: QueryClientValidation = {
|
|
22
|
+
columns: []
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function addColumnError(col: ColumnRef, message: string) {
|
|
26
|
+
const error = {message}
|
|
27
|
+
col.errors ||= []
|
|
28
|
+
col.errors.push(error)
|
|
29
|
+
validation.columns.push(error)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const usedNames: Set<string> = new Set<string>()
|
|
33
|
+
|
|
34
|
+
let isGrouped = false
|
|
35
|
+
Queries.eachColumn(query, (table, col) => {
|
|
36
|
+
// clear the errors
|
|
37
|
+
col.errors = undefined
|
|
38
|
+
|
|
39
|
+
// determine if there's a _group by_ in the query
|
|
40
|
+
if (col.grouped) {
|
|
41
|
+
isGrouped = true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// each select name should only be used once
|
|
45
|
+
const selectName = Columns.computeSelectName(table, col)
|
|
46
|
+
if (usedNames.has(selectName)) {
|
|
47
|
+
addColumnError(col, `<strong>${selectName}</strong> has already been selected for a different column`)
|
|
48
|
+
}
|
|
49
|
+
usedNames.add(selectName)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// if the query is grouped, ensure that all other column refs
|
|
53
|
+
// are either grouped or have an aggregate function
|
|
54
|
+
if (isGrouped) {
|
|
55
|
+
Queries.eachColumn(query, (_, col) => {
|
|
56
|
+
if (!col.grouped && !col.function) {
|
|
57
|
+
addColumnError(col, `<strong>${col.name}</strong> must be grouped or have an aggregate function`)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return validation
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
67
|
+
// Export
|
|
68
|
+
////////////////////////////////////////////////////////////////////////////////
|
|
69
|
+
|
|
70
|
+
const Validation = {
|
|
71
|
+
validateQuery
|
|
72
|
+
}
|
|
73
|
+
export default Validation
|
package/package.json
CHANGED
package/terrier/theme.ts
CHANGED
|
@@ -102,7 +102,7 @@ export default class Theme {
|
|
|
102
102
|
if (action.subtitle?.length) {
|
|
103
103
|
a.div('.subtitle', {text: action.subtitle})
|
|
104
104
|
}
|
|
105
|
-
|
|
105
|
+
if (!(action.title?.length || action.subtitle?.length)) {
|
|
106
106
|
a.class('icon-only')
|
|
107
107
|
}
|
|
108
108
|
if (action.href?.length) {
|