wip
This commit is contained in:
parent
fc31a28c10
commit
015ef56110
|
@ -10,10 +10,12 @@ import {
|
||||||
} from "./utils"
|
} from "./utils"
|
||||||
import SqlTableQueryBuilder from "./sqlTable"
|
import SqlTableQueryBuilder from "./sqlTable"
|
||||||
import {
|
import {
|
||||||
|
Aggregation,
|
||||||
AnySearchFilter,
|
AnySearchFilter,
|
||||||
ArrayOperator,
|
ArrayOperator,
|
||||||
BasicOperator,
|
BasicOperator,
|
||||||
BBReferenceFieldMetadata,
|
BBReferenceFieldMetadata,
|
||||||
|
CalculationType,
|
||||||
FieldSchema,
|
FieldSchema,
|
||||||
FieldType,
|
FieldType,
|
||||||
INTERNAL_TABLE_SOURCE_ID,
|
INTERNAL_TABLE_SOURCE_ID,
|
||||||
|
@ -789,6 +791,38 @@ class InternalBuilder {
|
||||||
return query.countDistinct(`${aliased}.${primary[0]} as total`)
|
return query.countDistinct(`${aliased}.${primary[0]} as total`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addAggregations(
|
||||||
|
query: Knex.QueryBuilder,
|
||||||
|
aggregations: Aggregation[]
|
||||||
|
): Knex.QueryBuilder {
|
||||||
|
const fields = this.query.resource?.fields || []
|
||||||
|
if (fields.length > 0) {
|
||||||
|
query = query.groupBy(fields.map(field => `${this.table.name}.${field}`))
|
||||||
|
}
|
||||||
|
for (const aggregation of aggregations) {
|
||||||
|
const op = aggregation.calculationType
|
||||||
|
const field = `${this.table.name}.${aggregation.field} as ${aggregation.name}`
|
||||||
|
switch (op) {
|
||||||
|
case CalculationType.COUNT:
|
||||||
|
query = query.count(field)
|
||||||
|
break
|
||||||
|
case CalculationType.SUM:
|
||||||
|
query = query.sum(field)
|
||||||
|
break
|
||||||
|
case CalculationType.AVG:
|
||||||
|
query = query.avg(field)
|
||||||
|
break
|
||||||
|
case CalculationType.MIN:
|
||||||
|
query = query.min(field)
|
||||||
|
break
|
||||||
|
case CalculationType.MAX:
|
||||||
|
query = query.max(field)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
addSorting(query: Knex.QueryBuilder): Knex.QueryBuilder {
|
addSorting(query: Knex.QueryBuilder): Knex.QueryBuilder {
|
||||||
let { sort } = this.query
|
let { sort } = this.query
|
||||||
const primaryKey = this.table.primary
|
const primaryKey = this.table.primary
|
||||||
|
@ -1172,10 +1206,18 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if counting, use distinct count, else select
|
const aggregations = this.query.resource?.aggregations || []
|
||||||
query = !counting
|
if (counting) {
|
||||||
? query.select(this.generateSelectStatement())
|
query = this.addDistinctCount(query)
|
||||||
: this.addDistinctCount(query)
|
} else if (aggregations.length > 0) {
|
||||||
|
query = query.select(
|
||||||
|
this.knex.raw("ROW_NUMBER() OVER (ORDER BY (SELECT 0)) as _id")
|
||||||
|
)
|
||||||
|
query = this.addAggregations(query, aggregations)
|
||||||
|
} else {
|
||||||
|
query = query.select(this.generateSelectStatement())
|
||||||
|
}
|
||||||
|
|
||||||
// have to add after as well (this breaks MS-SQL)
|
// have to add after as well (this breaks MS-SQL)
|
||||||
if (this.client !== SqlClient.MS_SQL && !counting) {
|
if (this.client !== SqlClient.MS_SQL && !counting) {
|
||||||
query = this.addSorting(query)
|
query = this.addSorting(query)
|
||||||
|
|
|
@ -7,8 +7,9 @@ import {
|
||||||
RowSearchParams,
|
RowSearchParams,
|
||||||
SearchFilterKey,
|
SearchFilterKey,
|
||||||
LogicalOperator,
|
LogicalOperator,
|
||||||
|
Aggregation,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { dataFilters } from "@budibase/shared-core"
|
import { dataFilters, helpers } from "@budibase/shared-core"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import { db, context, features } from "@budibase/backend-core"
|
import { db, context, features } from "@budibase/backend-core"
|
||||||
import { enrichSearchContext } from "./utils"
|
import { enrichSearchContext } from "./utils"
|
||||||
|
@ -27,7 +28,7 @@ export async function searchView(
|
||||||
ctx.throw(400, `This method only supports viewsV2`)
|
ctx.throw(400, `This method only supports viewsV2`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewFields = Object.entries(view.schema || {})
|
const viewFields = Object.entries(helpers.views.basicFields(view))
|
||||||
.filter(([_, value]) => value.visible)
|
.filter(([_, value]) => value.visible)
|
||||||
.map(([key]) => key)
|
.map(([key]) => key)
|
||||||
const { body } = ctx.request
|
const { body } = ctx.request
|
||||||
|
@ -74,6 +75,14 @@ export async function searchView(
|
||||||
user: sdk.users.getUserContextBindings(ctx.user),
|
user: sdk.users.getUserContextBindings(ctx.user),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const aggregations: Aggregation[] = Object.entries(
|
||||||
|
helpers.views.calculationFields(view)
|
||||||
|
).map(([name, { field, calculationType }]) => ({
|
||||||
|
name,
|
||||||
|
calculationType,
|
||||||
|
field,
|
||||||
|
}))
|
||||||
|
|
||||||
const searchOptions: RequiredKeys<SearchViewRowRequest> &
|
const searchOptions: RequiredKeys<SearchViewRowRequest> &
|
||||||
RequiredKeys<Pick<RowSearchParams, "tableId" | "query" | "fields">> = {
|
RequiredKeys<Pick<RowSearchParams, "tableId" | "query" | "fields">> = {
|
||||||
tableId: view.tableId,
|
tableId: view.tableId,
|
||||||
|
@ -84,6 +93,7 @@ export async function searchView(
|
||||||
bookmark: body.bookmark,
|
bookmark: body.bookmark,
|
||||||
paginate: body.paginate,
|
paginate: body.paginate,
|
||||||
countRows: body.countRows,
|
countRows: body.countRows,
|
||||||
|
aggregations,
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sdk.rows.search(searchOptions)
|
const result = await sdk.rows.search(searchOptions)
|
||||||
|
|
|
@ -8,8 +8,45 @@ import {
|
||||||
ViewResponse,
|
ViewResponse,
|
||||||
ViewResponseEnriched,
|
ViewResponseEnriched,
|
||||||
ViewV2,
|
ViewV2,
|
||||||
|
BasicViewUIFieldMetadata,
|
||||||
|
ViewCalculationFieldMetadata,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { builderSocket, gridSocket } from "../../../websockets"
|
import { builderSocket, gridSocket } from "../../../websockets"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
|
function stripUnknownFields(
|
||||||
|
field: ViewUIFieldMetadata
|
||||||
|
): RequiredKeys<ViewUIFieldMetadata> {
|
||||||
|
if (helpers.views.isCalculationField(field)) {
|
||||||
|
const strippedField: RequiredKeys<ViewCalculationFieldMetadata> = {
|
||||||
|
order: field.order,
|
||||||
|
width: field.width,
|
||||||
|
visible: field.visible,
|
||||||
|
readonly: field.readonly,
|
||||||
|
icon: field.icon,
|
||||||
|
calculationType: field.calculationType,
|
||||||
|
field: field.field,
|
||||||
|
}
|
||||||
|
return strippedField
|
||||||
|
} else {
|
||||||
|
const strippedField: RequiredKeys<BasicViewUIFieldMetadata> = {
|
||||||
|
order: field.order,
|
||||||
|
width: field.width,
|
||||||
|
visible: field.visible,
|
||||||
|
readonly: field.readonly,
|
||||||
|
icon: field.icon,
|
||||||
|
}
|
||||||
|
return strippedField
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripUndefinedFields(obj: Record<string, any>): void {
|
||||||
|
Object.keys(obj)
|
||||||
|
.filter(key => obj[key] === undefined)
|
||||||
|
.forEach(key => {
|
||||||
|
delete obj[key]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function parseSchema(view: CreateViewRequest) {
|
async function parseSchema(view: CreateViewRequest) {
|
||||||
if (!view.schema) {
|
if (!view.schema) {
|
||||||
|
@ -18,18 +55,8 @@ async function parseSchema(view: CreateViewRequest) {
|
||||||
const finalViewSchema =
|
const finalViewSchema =
|
||||||
view.schema &&
|
view.schema &&
|
||||||
Object.entries(view.schema).reduce((p, [fieldName, schemaValue]) => {
|
Object.entries(view.schema).reduce((p, [fieldName, schemaValue]) => {
|
||||||
const fieldSchema: RequiredKeys<ViewUIFieldMetadata> = {
|
const fieldSchema = stripUnknownFields(schemaValue)
|
||||||
order: schemaValue.order,
|
stripUndefinedFields(fieldSchema)
|
||||||
width: schemaValue.width,
|
|
||||||
visible: schemaValue.visible,
|
|
||||||
readonly: schemaValue.readonly,
|
|
||||||
icon: schemaValue.icon,
|
|
||||||
}
|
|
||||||
Object.entries(fieldSchema)
|
|
||||||
.filter(([, val]) => val === undefined)
|
|
||||||
.forEach(([key]) => {
|
|
||||||
delete fieldSchema[key as keyof ViewUIFieldMetadata]
|
|
||||||
})
|
|
||||||
p[fieldName] = fieldSchema
|
p[fieldName] = fieldSchema
|
||||||
return p
|
return p
|
||||||
}, {} as Record<string, RequiredKeys<ViewUIFieldMetadata>>)
|
}, {} as Record<string, RequiredKeys<ViewUIFieldMetadata>>)
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
ViewV2,
|
ViewV2,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
BasicOperator,
|
BasicOperator,
|
||||||
|
CalculationType,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { generator, mocks } from "@budibase/backend-core/tests"
|
import { generator, mocks } from "@budibase/backend-core/tests"
|
||||||
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
|
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
|
||||||
|
@ -32,13 +33,13 @@ import {
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
|
|
||||||
describe.each([
|
describe.each([
|
||||||
["lucene", undefined],
|
// ["lucene", undefined],
|
||||||
["sqs", undefined],
|
["sqs", undefined],
|
||||||
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
|
// [DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
|
||||||
[DatabaseName.MYSQL, getDatasource(DatabaseName.MYSQL)],
|
// [DatabaseName.MYSQL, getDatasource(DatabaseName.MYSQL)],
|
||||||
[DatabaseName.SQL_SERVER, getDatasource(DatabaseName.SQL_SERVER)],
|
// [DatabaseName.SQL_SERVER, getDatasource(DatabaseName.SQL_SERVER)],
|
||||||
[DatabaseName.MARIADB, getDatasource(DatabaseName.MARIADB)],
|
// [DatabaseName.MARIADB, getDatasource(DatabaseName.MARIADB)],
|
||||||
[DatabaseName.ORACLE, getDatasource(DatabaseName.ORACLE)],
|
// [DatabaseName.ORACLE, getDatasource(DatabaseName.ORACLE)],
|
||||||
])("/v2/views (%s)", (name, dsProvider) => {
|
])("/v2/views (%s)", (name, dsProvider) => {
|
||||||
const config = setup.getConfig()
|
const config = setup.getConfig()
|
||||||
const isSqs = name === "sqs"
|
const isSqs = name === "sqs"
|
||||||
|
@ -1978,6 +1979,64 @@ describe.each([
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("calculations", () => {
|
||||||
|
let table: Table
|
||||||
|
let rows: Row[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
quantity: {
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
name: "quantity",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
name: "price",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = await Promise.all(
|
||||||
|
Array.from({ length: 10 }, () =>
|
||||||
|
config.api.row.save(table._id!, {
|
||||||
|
quantity: generator.natural({ min: 1, max: 10 }),
|
||||||
|
price: generator.natural({ min: 1, max: 10 }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.only("should be able to search by calculations", async () => {
|
||||||
|
const view = await config.api.viewV2.create({
|
||||||
|
tableId: table._id!,
|
||||||
|
name: generator.guid(),
|
||||||
|
schema: {
|
||||||
|
"Quantity Sum": {
|
||||||
|
visible: true,
|
||||||
|
calculationType: CalculationType.SUM,
|
||||||
|
field: "quantity",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await config.api.viewV2.search(view.id, {
|
||||||
|
query: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.rows).toHaveLength(1)
|
||||||
|
expect(response.rows).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
"Quantity Sum": rows.reduce((acc, r) => acc + r.quantity, 0),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("permissions", () => {
|
describe("permissions", () => {
|
||||||
|
|
|
@ -46,6 +46,9 @@ export async function search(
|
||||||
paginate: options.paginate,
|
paginate: options.paginate,
|
||||||
fields: options.fields,
|
fields: options.fields,
|
||||||
countRows: options.countRows,
|
countRows: options.countRows,
|
||||||
|
aggregations: options.aggregations
|
||||||
|
?.map(a => `${a.field}:${a.calculationType}`)
|
||||||
|
.join(", "),
|
||||||
})
|
})
|
||||||
|
|
||||||
const isExternalTable = isExternalTableID(options.tableId)
|
const isExternalTable = isExternalTableID(options.tableId)
|
||||||
|
|
|
@ -48,6 +48,7 @@ import {
|
||||||
} from "@budibase/shared-core"
|
} from "@budibase/shared-core"
|
||||||
import { isSearchingByRowID } from "../utils"
|
import { isSearchingByRowID } from "../utils"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
|
import { cloneDeep } from "lodash"
|
||||||
|
|
||||||
const builder = new sql.Sql(SqlClient.SQL_LITE)
|
const builder = new sql.Sql(SqlClient.SQL_LITE)
|
||||||
const SQLITE_COLUMN_LIMIT = 2000
|
const SQLITE_COLUMN_LIMIT = 2000
|
||||||
|
@ -285,7 +286,7 @@ export async function search(
|
||||||
table: Table,
|
table: Table,
|
||||||
opts?: { retrying?: boolean }
|
opts?: { retrying?: boolean }
|
||||||
): Promise<SearchResponse<Row>> {
|
): Promise<SearchResponse<Row>> {
|
||||||
let { paginate, query, ...params } = options
|
let { paginate, query, ...params } = cloneDeep(options)
|
||||||
|
|
||||||
const allTables = await sdk.tables.getAllInternalTables()
|
const allTables = await sdk.tables.getAllInternalTables()
|
||||||
const allTablesMap = buildTableMap(allTables)
|
const allTablesMap = buildTableMap(allTables)
|
||||||
|
@ -303,6 +304,21 @@ export async function search(
|
||||||
...cleanupFilters(query, table, allTables),
|
...cleanupFilters(query, table, allTables),
|
||||||
documentType: DocumentType.ROW,
|
documentType: DocumentType.ROW,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fields = options.fields
|
||||||
|
if (fields === undefined) {
|
||||||
|
fields = buildInternalFieldList(table, allTables, { relationships })
|
||||||
|
} else {
|
||||||
|
fields = fields.map(f => mapToUserColumn(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.aggregations) {
|
||||||
|
options.aggregations = options.aggregations.map(a => {
|
||||||
|
a.field = mapToUserColumn(a.field)
|
||||||
|
return a
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const request: QueryJson = {
|
const request: QueryJson = {
|
||||||
endpoint: {
|
endpoint: {
|
||||||
// not important, we query ourselves
|
// not important, we query ourselves
|
||||||
|
@ -317,9 +333,7 @@ export async function search(
|
||||||
tables: allTablesMap,
|
tables: allTablesMap,
|
||||||
columnPrefix: USER_COLUMN_PREFIX,
|
columnPrefix: USER_COLUMN_PREFIX,
|
||||||
},
|
},
|
||||||
resource: {
|
resource: { fields, aggregations: options.aggregations },
|
||||||
fields: buildInternalFieldList(table, allTables, { relationships }),
|
|
||||||
},
|
|
||||||
relationships,
|
relationships,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -426,6 +440,7 @@ export async function search(
|
||||||
if (err.status === 400 && msg?.match(MISSING_COLUMN_REGEX)) {
|
if (err.status === 400 && msg?.match(MISSING_COLUMN_REGEX)) {
|
||||||
return { rows: [] }
|
return { rows: [] }
|
||||||
}
|
}
|
||||||
|
throw err
|
||||||
throw new Error(`Unable to search by SQL - ${msg}`, { cause: err })
|
throw new Error(`Unable to search by SQL - ${msg}`, { cause: err })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import {
|
import {
|
||||||
|
BasicViewUIFieldMetadata,
|
||||||
RenameColumn,
|
RenameColumn,
|
||||||
|
Table,
|
||||||
TableSchema,
|
TableSchema,
|
||||||
View,
|
View,
|
||||||
ViewUIFieldMetadata,
|
|
||||||
ViewV2,
|
ViewV2,
|
||||||
ViewV2Enriched,
|
ViewV2Enriched,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
@ -38,31 +39,84 @@ export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
|
||||||
return pickApi(tableId).getEnriched(viewId)
|
return pickApi(tableId).getEnriched(viewId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function guardCalculationViewSchema(
|
||||||
|
table: Table,
|
||||||
|
view: Omit<ViewV2, "id" | "version">
|
||||||
|
) {
|
||||||
|
const calculationFields = helpers.views.calculationFields(view)
|
||||||
|
for (const calculationFieldName of Object.keys(calculationFields)) {
|
||||||
|
const schema = calculationFields[calculationFieldName]
|
||||||
|
const targetSchema = table.schema[schema.field]
|
||||||
|
if (!targetSchema) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`Calculation field "${calculationFieldName}" references field "${schema.field}" which does not exist in the table schema`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!helpers.schema.isNumeric(targetSchema)) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`Calculation field "${calculationFieldName}" references field "${schema.field}" which is not a numeric field`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupByFields = helpers.views.basicFields(view)
|
||||||
|
for (const groupByFieldName of Object.keys(groupByFields)) {
|
||||||
|
const targetSchema = table.schema[groupByFieldName]
|
||||||
|
if (!targetSchema) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`Group by field "${groupByFieldName}" does not exist in the table schema`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function guardViewSchema(
|
async function guardViewSchema(
|
||||||
tableId: string,
|
tableId: string,
|
||||||
view: Omit<ViewV2, "id" | "version">
|
view: Omit<ViewV2, "id" | "version">
|
||||||
) {
|
) {
|
||||||
const viewSchema = view.schema || {}
|
|
||||||
const table = await sdk.tables.getTable(tableId)
|
const table = await sdk.tables.getTable(tableId)
|
||||||
|
|
||||||
|
if (helpers.views.isCalculationView(view)) {
|
||||||
|
await guardCalculationViewSchema(table, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkReadonlyFields(table, view)
|
||||||
|
checkRequiredFields(table, view)
|
||||||
|
checkDisplayField(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkReadonlyFields(
|
||||||
|
table: Table,
|
||||||
|
view: Omit<ViewV2, "id" | "version">
|
||||||
|
) {
|
||||||
|
const viewSchema = view.schema || {}
|
||||||
for (const field of Object.keys(viewSchema)) {
|
for (const field of Object.keys(viewSchema)) {
|
||||||
const tableSchemaField = table.schema[field]
|
const viewFieldSchema = viewSchema[field]
|
||||||
if (!tableSchemaField) {
|
if (helpers.views.isCalculationField(viewFieldSchema)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableFieldSchema = table.schema[field]
|
||||||
|
if (!tableFieldSchema) {
|
||||||
throw new HTTPError(
|
throw new HTTPError(
|
||||||
`Field "${field}" is not valid for the requested table`,
|
`Field "${field}" is not valid for the requested table`,
|
||||||
400
|
400
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewSchema[field].readonly) {
|
if (viewFieldSchema.readonly) {
|
||||||
if (
|
if (
|
||||||
!(await features.isViewReadonlyColumnsEnabled()) &&
|
!(await features.isViewReadonlyColumnsEnabled()) &&
|
||||||
!(tableSchemaField as ViewUIFieldMetadata).readonly
|
!(tableFieldSchema as BasicViewUIFieldMetadata).readonly
|
||||||
) {
|
) {
|
||||||
throw new HTTPError(`Readonly fields are not enabled`, 400)
|
throw new HTTPError(`Readonly fields are not enabled`, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!viewSchema[field].visible) {
|
if (!viewFieldSchema.visible) {
|
||||||
throw new HTTPError(
|
throw new HTTPError(
|
||||||
`Field "${field}" must be visible if you want to make it readonly`,
|
`Field "${field}" must be visible if you want to make it readonly`,
|
||||||
400
|
400
|
||||||
|
@ -70,18 +124,33 @@ async function guardViewSchema(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingView =
|
function checkDisplayField(view: Omit<ViewV2, "id" | "version">) {
|
||||||
table?.views && (table.views[view.name] as ViewV2 | undefined)
|
if (view.primaryDisplay) {
|
||||||
|
const viewSchemaField = view.schema?.[view.primaryDisplay]
|
||||||
|
|
||||||
|
if (!viewSchemaField?.visible) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`You can't hide "${view.primaryDisplay}" because it is the display column.`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRequiredFields(
|
||||||
|
table: Table,
|
||||||
|
view: Omit<ViewV2, "id" | "version">
|
||||||
|
) {
|
||||||
|
const existingView = table.views?.[view.name] as ViewV2 | undefined
|
||||||
for (const field of Object.values(table.schema)) {
|
for (const field of Object.values(table.schema)) {
|
||||||
if (!helpers.schema.isRequired(field.constraints)) {
|
if (!helpers.schema.isRequired(field.constraints)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewSchemaField = viewSchema[field.name]
|
const viewSchemaField = view.schema?.[field.name]
|
||||||
const existingViewSchema =
|
const existingViewSchema = existingView?.schema?.[field.name]
|
||||||
existingView?.schema && existingView.schema[field.name]
|
|
||||||
if (!viewSchemaField && !existingViewSchema?.visible) {
|
if (!viewSchemaField && !existingViewSchema?.visible) {
|
||||||
// Supporting existing configs with required columns but hidden in views
|
// Supporting existing configs with required columns but hidden in views
|
||||||
continue
|
continue
|
||||||
|
@ -94,24 +163,16 @@ async function guardViewSchema(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewSchemaField.readonly) {
|
if (
|
||||||
|
helpers.views.isBasicViewField(viewSchemaField) &&
|
||||||
|
viewSchemaField.readonly
|
||||||
|
) {
|
||||||
throw new HTTPError(
|
throw new HTTPError(
|
||||||
`You can't make "${field.name}" readonly because it is a required field.`,
|
`You can't make "${field.name}" readonly because it is a required field.`,
|
||||||
400
|
400
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view.primaryDisplay) {
|
|
||||||
const viewSchemaField = viewSchema[view.primaryDisplay]
|
|
||||||
|
|
||||||
if (!viewSchemaField?.visible) {
|
|
||||||
throw new HTTPError(
|
|
||||||
`You can't hide "${view.primaryDisplay}" because it is the display column.`,
|
|
||||||
400
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(
|
export async function create(
|
||||||
|
|
|
@ -2,3 +2,4 @@ export * from "./helpers"
|
||||||
export * from "./integrations"
|
export * from "./integrations"
|
||||||
export * as cron from "./cron"
|
export * as cron from "./cron"
|
||||||
export * as schema from "./schema"
|
export * as schema from "./schema"
|
||||||
|
export * as views from "./views"
|
||||||
|
|
|
@ -45,3 +45,7 @@ export function decodeNonAscii(str: string): string {
|
||||||
String.fromCharCode(parseInt(p1, 16))
|
String.fromCharCode(parseInt(p1, 16))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNumeric(field: FieldSchema) {
|
||||||
|
return field.type === FieldType.NUMBER || field.type === FieldType.BIGINT
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import {
|
||||||
|
BasicViewUIFieldMetadata,
|
||||||
|
ViewCalculationFieldMetadata,
|
||||||
|
ViewUIFieldMetadata,
|
||||||
|
ViewV2,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { pickBy } from "lodash"
|
||||||
|
|
||||||
|
export function isCalculationField(
|
||||||
|
field: ViewUIFieldMetadata
|
||||||
|
): field is ViewCalculationFieldMetadata {
|
||||||
|
return "calculationType" in field
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBasicViewField(
|
||||||
|
field: ViewUIFieldMetadata
|
||||||
|
): field is BasicViewUIFieldMetadata {
|
||||||
|
return !isCalculationField(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnsavedViewV2 = Omit<ViewV2, "id" | "version">
|
||||||
|
|
||||||
|
export function isCalculationView(view: UnsavedViewV2) {
|
||||||
|
return Object.values(view.schema || {}).some(isCalculationField)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculationFields(view: UnsavedViewV2) {
|
||||||
|
if (!isCalculationView(view)) {
|
||||||
|
throw new Error("View is not a calculation view")
|
||||||
|
}
|
||||||
|
return pickBy(view.schema || {}, isCalculationField)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function basicFields(view: UnsavedViewV2) {
|
||||||
|
if (!isCalculationView(view)) {
|
||||||
|
throw new Error("View is not a calculation view")
|
||||||
|
}
|
||||||
|
return pickBy(view.schema || {}, field => !isCalculationField(field))
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ export interface SearchViewRowRequest
|
||||||
| "paginate"
|
| "paginate"
|
||||||
| "query"
|
| "query"
|
||||||
| "countRows"
|
| "countRows"
|
||||||
|
| "aggregations"
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
export interface SearchRowResponse {
|
export interface SearchRowResponse {
|
||||||
|
|
|
@ -33,10 +33,19 @@ export interface View {
|
||||||
groupBy?: string
|
groupBy?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewUIFieldMetadata = UIFieldMetadata & {
|
export interface BasicViewUIFieldMetadata extends UIFieldMetadata {
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ViewCalculationFieldMetadata extends BasicViewUIFieldMetadata {
|
||||||
|
calculationType: CalculationType
|
||||||
|
field: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewUIFieldMetadata =
|
||||||
|
| BasicViewUIFieldMetadata
|
||||||
|
| ViewCalculationFieldMetadata
|
||||||
|
|
||||||
export enum CalculationType {
|
export enum CalculationType {
|
||||||
SUM = "sum",
|
SUM = "sum",
|
||||||
AVG = "avg",
|
AVG = "avg",
|
||||||
|
@ -45,11 +54,6 @@ export enum CalculationType {
|
||||||
MAX = "max",
|
MAX = "max",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewCalculationFieldMetadata = ViewUIFieldMetadata & {
|
|
||||||
calculationType: CalculationType
|
|
||||||
field: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ViewV2 {
|
export interface ViewV2 {
|
||||||
version: 2
|
version: 2
|
||||||
id: string
|
id: string
|
||||||
|
@ -62,7 +66,7 @@ export interface ViewV2 {
|
||||||
order?: SortOrder
|
order?: SortOrder
|
||||||
type?: SortType
|
type?: SortType
|
||||||
}
|
}
|
||||||
schema?: Record<string, ViewUIFieldMetadata | ViewCalculationFieldMetadata>
|
schema?: Record<string, ViewUIFieldMetadata>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema
|
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
import { SortOrder, SortType } from "../api"
|
import { SortOrder, SortType } from "../api"
|
||||||
import { SearchFilters } from "./search"
|
import { SearchFilters } from "./search"
|
||||||
import { Row } from "../documents"
|
import { CalculationType, Row } from "../documents"
|
||||||
import { WithRequired } from "../shared"
|
import { WithRequired } from "../shared"
|
||||||
|
|
||||||
|
export interface Aggregation {
|
||||||
|
name: string
|
||||||
|
calculationType: CalculationType
|
||||||
|
field: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
tableId?: string
|
tableId?: string
|
||||||
query?: SearchFilters
|
query?: SearchFilters
|
||||||
|
@ -18,6 +24,7 @@ export interface SearchParams {
|
||||||
indexer?: () => Promise<any>
|
indexer?: () => Promise<any>
|
||||||
rows?: Row[]
|
rows?: Row[]
|
||||||
countRows?: boolean
|
countRows?: boolean
|
||||||
|
aggregations?: Aggregation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// when searching for rows we want a more extensive search type that requires certain properties
|
// when searching for rows we want a more extensive search type that requires certain properties
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Operation } from "./datasources"
|
||||||
import { Row, Table, DocumentType } from "../documents"
|
import { Row, Table, DocumentType } from "../documents"
|
||||||
import { SortOrder, SortType } from "../api"
|
import { SortOrder, SortType } from "../api"
|
||||||
import { Knex } from "knex"
|
import { Knex } from "knex"
|
||||||
|
import { Aggregation } from "./row"
|
||||||
|
|
||||||
export enum BasicOperator {
|
export enum BasicOperator {
|
||||||
EQUAL = "equal",
|
EQUAL = "equal",
|
||||||
|
@ -143,6 +144,7 @@ export interface QueryJson {
|
||||||
}
|
}
|
||||||
resource?: {
|
resource?: {
|
||||||
fields: string[]
|
fields: string[]
|
||||||
|
aggregations?: Aggregation[]
|
||||||
}
|
}
|
||||||
filters?: SearchFilters
|
filters?: SearchFilters
|
||||||
sort?: SortJson
|
sort?: SortJson
|
||||||
|
|
|
@ -4,6 +4,29 @@ export type DeepPartial<T> = {
|
||||||
|
|
||||||
export type ISO8601 = string
|
export type ISO8601 = string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RequiredKeys make it such that you _must_ assign a value to every key in the
|
||||||
|
* type. It differs subtly from Required<T> in that it doesn't change the type
|
||||||
|
* of the fields, you can specify undefined as a value and that's fine.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* interface Foo {
|
||||||
|
* bar: string
|
||||||
|
* baz?: string
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* type FooRequiredKeys = RequiredKeys<Foo>
|
||||||
|
* type FooRequired = Required<Foo>
|
||||||
|
*
|
||||||
|
* const a: FooRequiredKeys = { bar: "hello", baz: undefined }
|
||||||
|
* const b: FooRequired = { bar: "hello", baz: undefined }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* In this code, a passes type checking whereas b does not. This is because
|
||||||
|
* Required<Foo> makes baz non-optional.
|
||||||
|
*/
|
||||||
export type RequiredKeys<T> = {
|
export type RequiredKeys<T> = {
|
||||||
[K in keyof Required<T>]: T[K]
|
[K in keyof Required<T>]: T[K]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue