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.
@@ -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
- export const DirectOperators = ['eq', 'ne', 'ilike', 'lt', 'gt', 'lte', 'gte'] as const
31
- export type DirectOperator = typeof DirectOperators[number]
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 operatorOptions = DirectOperators.map(op => {
373
- return {title: operatorDisplay(op), value: op}
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
- // Validation
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 QueryValidation = ApiResponse & {
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<QueryValidation> {
51
- return await api.post<QueryValidation>("/data_dive/validate_query.json", {query})
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, QueryValidation} from "./queries"
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<QueryValidation>()
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?: QueryValidation
64
+ validation?: QueryServerValidation
64
65
 
65
- setValidation(validation: QueryValidation) {
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.updatedJoinedViews()
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.updatedJoinedViews()
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
- updatedJoinedViews() {
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
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  "*"
6
6
  ],
7
- "version": "4.7.0",
7
+ "version": "4.7.4",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/Terrier-Tech/terrier-engine"
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
- else {
105
+ if (!(action.title?.length || action.subtitle?.length)) {
106
106
  a.class('icon-only')
107
107
  }
108
108
  if (action.href?.length) {