@nuxtagentic/data 0.0.0-stage → 0.1.1
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/LICENSE.md +9 -0
- package/README.md +17 -2
- package/module.ts +19 -0
- package/package.json +40 -3
- package/runtime/composables/useWorkspaceQuery.ts +38 -0
- package/runtime/composables/useWorkspaceRecord.ts +26 -0
- package/runtime/composables/useWorkspaceRefresh.ts +15 -0
- package/runtime/server/utils/cloudconfig-errors.ts +13 -0
- package/runtime/shared/date-periods.ts +30 -0
- package/runtime/shared/dates.ts +26 -0
- package/runtime/shared/model-search.ts +45 -0
- package/runtime/shared/model-views.ts +105 -0
- package/runtime/shared/query/filters.ts +190 -0
- package/runtime/shared/query/sql.ts +312 -0
- package/runtime/shared/query/types.ts +28 -0
- package/runtime/shared/query/values.ts +41 -0
- package/runtime/shared/resource-query.ts +11 -0
- package/runtime/shared/table-query.ts +26 -0
- package/runtime/shared/upload-limits.ts +3 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Proprietary software. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Use of Nuxt Agentic packages and starter materials is governed by the applicable
|
|
4
|
+
commercial agreement supplied with a purchase. This repository does not grant
|
|
5
|
+
an open-source license or permission to redistribute the packages as a competing
|
|
6
|
+
template or module library.
|
|
7
|
+
|
|
8
|
+
Third-party dependencies retain their respective licenses. This notice does not
|
|
9
|
+
replace or modify those licenses.
|
package/README.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Data
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@nuxtagentic/data` provides the shared query and table infrastructure. It depends
|
|
4
|
+
on Identity for the active account and organization scope and has no models or
|
|
5
|
+
collection UI of its own.
|
|
6
|
+
|
|
7
|
+
`useWorkspaceQuery` shares equivalent list requests within a scope.
|
|
8
|
+
`useWorkspaceRecord` loads individual records, and `useWorkspaceRefresh` refreshes
|
|
9
|
+
related lists after mutations. The shared query code validates fields, filters,
|
|
10
|
+
sorting, and pagination against registered model views.
|
|
11
|
+
|
|
12
|
+
`runtime/shared/model-views.ts` defines the model-view contract and
|
|
13
|
+
`defineModelView`. Features own their actual view definitions and publish them
|
|
14
|
+
through their metadata contributions. Data does not import feature schemas or
|
|
15
|
+
the host config.
|
|
16
|
+
|
|
17
|
+
Install it through a dependent feature, or add `@nuxtagentic/data` to Nuxt's
|
|
18
|
+
`modules` when using the query helpers directly. See the [module map](../README.md).
|
package/module.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
2
|
+
import { registerWorkspaceModule } from '@nuxtagentic/kit'
|
|
3
|
+
|
|
4
|
+
export default defineNuxtModule({
|
|
5
|
+
meta: { name: '@nuxtagentic/data' },
|
|
6
|
+
moduleDependencies: {
|
|
7
|
+
'@nuxtagentic/identity': {},
|
|
8
|
+
},
|
|
9
|
+
setup(_options, nuxt) {
|
|
10
|
+
const resolver = createResolver(import.meta.url)
|
|
11
|
+
registerWorkspaceModule(
|
|
12
|
+
{
|
|
13
|
+
id: 'data',
|
|
14
|
+
runtime: resolver.resolve('./runtime'),
|
|
15
|
+
},
|
|
16
|
+
nuxt,
|
|
17
|
+
)
|
|
18
|
+
},
|
|
19
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxtagentic/data",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"module.ts",
|
|
7
|
+
"runtime",
|
|
8
|
+
"README.md",
|
|
9
|
+
"LICENSE.md"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./module.ts",
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
"./runtime/*.vue": "./runtime/*.vue",
|
|
15
|
+
"./runtime/*.css": "./runtime/*.css",
|
|
16
|
+
"./runtime/*.json": "./runtime/*.json",
|
|
17
|
+
"./runtime/*.svg": "./runtime/*.svg",
|
|
18
|
+
"./runtime/*": "./runtime/*.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@cloudconfig/core": "0.2.0-beta.2",
|
|
22
|
+
"@nuxt/kit": "4.6.1",
|
|
23
|
+
"h3": "1.15.11",
|
|
24
|
+
"@nuxtagentic/identity": "0.1.1",
|
|
25
|
+
"@nuxtagentic/kit": "0.1.1"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"nuxt": "^4.6.1",
|
|
29
|
+
"vue": "^3.5.43"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"tsx": "^4.23.15"
|
|
33
|
+
},
|
|
34
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
35
|
+
"description": "Scoped typed records and queries for Nuxt Agentic.",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"registry": "https://registry.npmjs.org/"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
|
42
|
+
}
|
|
6
43
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useWorkspaceScope } from '@nuxtagentic/identity/runtime/composables/useWorkspaceScope'
|
|
2
|
+
import { computed, type Ref } from 'vue'
|
|
3
|
+
import { useAsyncData } from '#imports'
|
|
4
|
+
import { useWorkspaceRefresh } from './useWorkspaceRefresh'
|
|
5
|
+
|
|
6
|
+
export function useWorkspaceQuery<Row, Query>(
|
|
7
|
+
name: string,
|
|
8
|
+
query: Ref<Query>,
|
|
9
|
+
fetchRows: (query: Query, signal: AbortSignal) => Promise<{ data: Row[]; totalCount: number }>,
|
|
10
|
+
) {
|
|
11
|
+
const scope = useWorkspaceScope()
|
|
12
|
+
const refreshAll = useWorkspaceRefresh(name)
|
|
13
|
+
const prefix = computed(() => `workspace:${name}:${scope.value}:list:`)
|
|
14
|
+
// Equivalent queries share data and in-flight requests across dashboard surfaces.
|
|
15
|
+
const key = computed(() => `${prefix.value}${JSON.stringify(query.value)}`)
|
|
16
|
+
const { data, status, pending, error, refresh } = useAsyncData(
|
|
17
|
+
key,
|
|
18
|
+
(_, { signal }) =>
|
|
19
|
+
scope.value ? fetchRows(query.value, signal) : Promise.resolve({ data: [], totalCount: 0 }),
|
|
20
|
+
{
|
|
21
|
+
lazy: true,
|
|
22
|
+
deep: false,
|
|
23
|
+
dedupe: 'defer',
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
scope,
|
|
29
|
+
rows: computed<Row[]>(() => data.value?.data ?? []),
|
|
30
|
+
totalCount: computed(() => data.value?.totalCount ?? 0),
|
|
31
|
+
isLoading: computed(() => status.value === 'idle' || status.value === 'pending'),
|
|
32
|
+
status,
|
|
33
|
+
pending,
|
|
34
|
+
error,
|
|
35
|
+
refresh,
|
|
36
|
+
refreshAll,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useAsyncData } from '#imports'
|
|
2
|
+
import { computed, type Ref } from 'vue'
|
|
3
|
+
export interface WorkspaceRecordSource<Row extends { id: number | string }> {
|
|
4
|
+
id: Ref<Row['id'] | null>
|
|
5
|
+
/** The workspace the record belongs to; changing it refetches under a new cache key. */
|
|
6
|
+
scope: Ref<string>
|
|
7
|
+
get: (id: Row['id']) => Promise<Row>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Loads one record of a workspace model, or `null` while nothing is selected. */
|
|
11
|
+
export function useWorkspaceRecord<Row extends { id: number | string }>(
|
|
12
|
+
name: string,
|
|
13
|
+
{ id, scope, get }: WorkspaceRecordSource<Row>,
|
|
14
|
+
{ server = false }: { server?: boolean } = {},
|
|
15
|
+
) {
|
|
16
|
+
return useAsyncData(
|
|
17
|
+
computed(() => `workspace:${name}:${scope.value}:detail:${id.value}`),
|
|
18
|
+
async () => {
|
|
19
|
+
if (id.value === null || !scope.value) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
return get(id.value)
|
|
23
|
+
},
|
|
24
|
+
{ server, default: () => null },
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useNuxtApp, refreshNuxtData } from '#imports'
|
|
2
|
+
import { useWorkspaceScope } from '@nuxtagentic/identity/runtime/composables/useWorkspaceScope'
|
|
3
|
+
|
|
4
|
+
/** Refresh active queries for a resource in the current user/workspace scope. */
|
|
5
|
+
export function useWorkspaceRefresh(name: string) {
|
|
6
|
+
const scope = useWorkspaceScope()
|
|
7
|
+
const nuxtApp = useNuxtApp()
|
|
8
|
+
|
|
9
|
+
return () => {
|
|
10
|
+
const prefix = `workspace:${name}:${scope.value}:list:`
|
|
11
|
+
return refreshNuxtData(
|
|
12
|
+
Object.keys(nuxtApp.payload.data).filter((key) => key.startsWith(prefix)),
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createError } from 'h3'
|
|
2
|
+
import { CloudConfigClientError } from '@cloudconfig/core/client'
|
|
3
|
+
|
|
4
|
+
export function throwCloudConfigError(cause: unknown): never {
|
|
5
|
+
if (cause instanceof CloudConfigClientError) {
|
|
6
|
+
throw createError({
|
|
7
|
+
statusCode: cause.status,
|
|
8
|
+
statusMessage: cause.message,
|
|
9
|
+
data: { issues: cause.issues },
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
throw cause
|
|
13
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const MONTHS = [
|
|
2
|
+
'january',
|
|
3
|
+
'february',
|
|
4
|
+
'march',
|
|
5
|
+
'april',
|
|
6
|
+
'may',
|
|
7
|
+
'june',
|
|
8
|
+
'july',
|
|
9
|
+
'august',
|
|
10
|
+
'september',
|
|
11
|
+
'october',
|
|
12
|
+
'november',
|
|
13
|
+
'december',
|
|
14
|
+
]
|
|
15
|
+
const MONTH_PATTERN = new RegExp(
|
|
16
|
+
`\\b(${[...MONTHS, ...MONTHS.map((month) => month.slice(0, 3)), 'sept'].join('|')})(?:\\s+(\\d{4}))?\\b`,
|
|
17
|
+
'gi',
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
/** Named months cover the whole month, defaulting to the current UTC year. */
|
|
21
|
+
export function findNamedMonthPeriods(source: string, now = new Date()) {
|
|
22
|
+
return [...source.matchAll(MONTH_PATTERN)].map((match) => {
|
|
23
|
+
const month = MONTHS.findIndex((name) => name.startsWith(match[1]!.toLowerCase()))
|
|
24
|
+
const year = match[2] ?? String(now.getUTCFullYear())
|
|
25
|
+
const start = new Date(`${year}-${String(month + 1).padStart(2, '0')}-01T00:00:00.000Z`)
|
|
26
|
+
const end = new Date(start)
|
|
27
|
+
end.setUTCMonth(end.getUTCMonth() + 1)
|
|
28
|
+
return { label: match[0], start: start.toISOString(), end: end.toISOString() }
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function toDateKey(date: Date) {
|
|
2
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function toLocalDate(key: string) {
|
|
6
|
+
return new Date(`${key}T00:00:00`)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isValidDateKey(key: unknown): key is string {
|
|
10
|
+
return (
|
|
11
|
+
typeof key === 'string' &&
|
|
12
|
+
/^\d{4}-\d{2}-\d{2}$/.test(key) &&
|
|
13
|
+
Number.isFinite(toLocalDate(key).getTime()) &&
|
|
14
|
+
toDateKey(toLocalDate(key)) === key
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function addDays(date: Date, days: number) {
|
|
19
|
+
const next = new Date(date)
|
|
20
|
+
next.setDate(next.getDate() + days)
|
|
21
|
+
return next
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getWeekStart(date: Date) {
|
|
25
|
+
return addDays(toLocalDate(toDateKey(date)), -((date.getDay() + 6) % 7))
|
|
26
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { TableQuery } from '@cloudconfig/core/client'
|
|
2
|
+
import type { ModelView } from './model-views'
|
|
3
|
+
import type { FilterGroup, FilterNode } from '@cloudconfig/core'
|
|
4
|
+
|
|
5
|
+
/** Apply text search only to the properties the UI explicitly enables. */
|
|
6
|
+
export function withModelSearch<Key extends string>(
|
|
7
|
+
query: TableQuery<Key>,
|
|
8
|
+
view: ModelView,
|
|
9
|
+
): TableQuery<Key> {
|
|
10
|
+
const wireKey = (key: string) =>
|
|
11
|
+
view.fields.find((field) => field.key === key)?.kind === 'relation' ? `${key}Id` : key
|
|
12
|
+
const wireFilter = (node: FilterNode): FilterNode =>
|
|
13
|
+
node.type === 'group'
|
|
14
|
+
? { ...node, children: node.children.map(wireFilter) }
|
|
15
|
+
: { ...node, field: wireKey(node.field) }
|
|
16
|
+
const base = {
|
|
17
|
+
...query,
|
|
18
|
+
sortBy: query.sortBy ? (wireKey(query.sortBy) as Key) : undefined,
|
|
19
|
+
filters: query.filters ? (wireFilter(query.filters) as FilterGroup) : undefined,
|
|
20
|
+
}
|
|
21
|
+
const search = query.search?.trim()
|
|
22
|
+
if (!search) {
|
|
23
|
+
return { ...base, search: undefined }
|
|
24
|
+
}
|
|
25
|
+
const filters: FilterGroup = {
|
|
26
|
+
type: 'group',
|
|
27
|
+
logic: 'and',
|
|
28
|
+
children: [
|
|
29
|
+
...(base.filters ? [base.filters] : []),
|
|
30
|
+
{
|
|
31
|
+
type: 'group',
|
|
32
|
+
logic: 'or',
|
|
33
|
+
children: view.search.length
|
|
34
|
+
? view.search.map((field) => ({
|
|
35
|
+
type: 'condition',
|
|
36
|
+
field: wireKey(field),
|
|
37
|
+
operator: 'like',
|
|
38
|
+
value: search,
|
|
39
|
+
}))
|
|
40
|
+
: [{ type: 'condition', field: 'id', operator: 'eq', value: -1 }],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
return { ...base, search: undefined, filters }
|
|
45
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getFieldSpec, type AnyConfig, type FieldSpec, type InferSelect } from '@cloudconfig/core'
|
|
2
|
+
|
|
3
|
+
export type ModelRow = { id: number; [key: string]: unknown }
|
|
4
|
+
export type ModelField = FieldSpec & { key: string; label: string }
|
|
5
|
+
|
|
6
|
+
export interface ModelView<Key extends string = string> {
|
|
7
|
+
id: string
|
|
8
|
+
model: string
|
|
9
|
+
label: string
|
|
10
|
+
icon: string
|
|
11
|
+
aliases: readonly string[]
|
|
12
|
+
fields: readonly ModelField[]
|
|
13
|
+
columns: readonly Key[]
|
|
14
|
+
search: readonly Key[]
|
|
15
|
+
filter: readonly Key[]
|
|
16
|
+
sort: readonly Key[]
|
|
17
|
+
actions: readonly ('create' | 'update' | 'delete')[]
|
|
18
|
+
selectionKey: string
|
|
19
|
+
/** Deterministic value semantics, separate from language interpretation. */
|
|
20
|
+
query?: {
|
|
21
|
+
nullAsFalse?: readonly Key[]
|
|
22
|
+
units?: Partial<Record<Key, 'bytes'>>
|
|
23
|
+
}
|
|
24
|
+
/** Optional domain vocabulary for schema-derived natural-language queries. */
|
|
25
|
+
prompt?: {
|
|
26
|
+
instructions?: string
|
|
27
|
+
fields?: Partial<Record<Key, string>>
|
|
28
|
+
/** Stored values mapped to domain vocabulary, e.g. admin: "administrator". */
|
|
29
|
+
values?: Partial<Record<Key, Record<string, string>>>
|
|
30
|
+
/** Override the global slot limit only where a model needs it. */
|
|
31
|
+
maxConstraints?: Partial<Record<Key, number>>
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ViewOptions<Key extends string> = Pick<
|
|
36
|
+
ModelView<Key>,
|
|
37
|
+
| 'id'
|
|
38
|
+
| 'label'
|
|
39
|
+
| 'icon'
|
|
40
|
+
| 'aliases'
|
|
41
|
+
| 'columns'
|
|
42
|
+
| 'search'
|
|
43
|
+
| 'filter'
|
|
44
|
+
| 'sort'
|
|
45
|
+
| 'actions'
|
|
46
|
+
| 'prompt'
|
|
47
|
+
| 'query'
|
|
48
|
+
>
|
|
49
|
+
|
|
50
|
+
/** Only public wire fields can be configured; types and controls come from CloudConfig. */
|
|
51
|
+
export function defineModelView<
|
|
52
|
+
Backend extends AnyConfig,
|
|
53
|
+
Name extends keyof Backend['models'] & string,
|
|
54
|
+
>(
|
|
55
|
+
backend: Backend,
|
|
56
|
+
name: Name,
|
|
57
|
+
options: ViewOptions<keyof InferSelect<Backend, Name> & string>,
|
|
58
|
+
): ModelView {
|
|
59
|
+
const model = backend.models[name]
|
|
60
|
+
if (!model) {
|
|
61
|
+
throw new Error(`Model "${name}" is not registered in this schema.`)
|
|
62
|
+
}
|
|
63
|
+
const fields: ModelField[] = Object.entries(model.fields).flatMap(([key, field]) => {
|
|
64
|
+
const spec = getFieldSpec(field)
|
|
65
|
+
return spec.serverOnly ? [] : [{ ...spec, key, label: spec.label ?? fieldLabel(key) }]
|
|
66
|
+
})
|
|
67
|
+
fields.unshift({ ...baseField('number'), key: 'id', label: 'ID' })
|
|
68
|
+
if (model.options.timestamps !== false) {
|
|
69
|
+
fields.push(
|
|
70
|
+
{ ...baseField('datetime'), key: 'createdAt', label: 'Created' },
|
|
71
|
+
{ ...baseField('datetime'), key: 'updatedAt', label: 'Updated' },
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
return { ...options, model: name, fields, selectionKey: `${options.id}-record` }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function baseField(kind: FieldSpec['kind']): FieldSpec {
|
|
78
|
+
return { kind, required: false, unique: false, hasDefault: false }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fieldLabel(key: string) {
|
|
82
|
+
const text = key.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
83
|
+
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getViewColumns(view: ModelView) {
|
|
87
|
+
const { columns, fields } = view
|
|
88
|
+
return columns.map((key) => ({
|
|
89
|
+
key,
|
|
90
|
+
label: fields.find((field) => field.key === key)!.label,
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatModelValue(value: unknown): string {
|
|
95
|
+
if (value === null || value === undefined) {
|
|
96
|
+
return '—'
|
|
97
|
+
}
|
|
98
|
+
if (typeof value === 'boolean') {
|
|
99
|
+
return value ? 'Yes' : 'No'
|
|
100
|
+
}
|
|
101
|
+
if (typeof value === 'object') {
|
|
102
|
+
return JSON.stringify(value)
|
|
103
|
+
}
|
|
104
|
+
return String(value)
|
|
105
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { TableQuery } from '@cloudconfig/core/client'
|
|
2
|
+
import type { FilterGroup, FilterNode, FilterOperator, FilterValue } from '@cloudconfig/core'
|
|
3
|
+
import type { ModelView } from '../model-views'
|
|
4
|
+
import type { Comparison, ResourceQuery, Where } from './types'
|
|
5
|
+
import { toBooleanValue } from './values'
|
|
6
|
+
|
|
7
|
+
/** Translate prompt queries to the same server filters used by CloudConfig tables. */
|
|
8
|
+
export function toResourceTableQuery<Key extends string>(
|
|
9
|
+
query: ResourceQuery<Key>,
|
|
10
|
+
view: ModelView<Key>,
|
|
11
|
+
): TableQuery<Key> {
|
|
12
|
+
function translate(node: Where<Key>, negate = false): FilterNode {
|
|
13
|
+
if (node.type === 'search') {
|
|
14
|
+
if (!view.search.length) {
|
|
15
|
+
throw new Error('Text search is not enabled for this model.')
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
type: 'group',
|
|
19
|
+
logic: negate ? 'and' : 'or',
|
|
20
|
+
children: view.search.map((field) => ({
|
|
21
|
+
type: 'condition',
|
|
22
|
+
field,
|
|
23
|
+
operator: negate ? 'not_contains' : 'like',
|
|
24
|
+
value: node.value,
|
|
25
|
+
})),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (node.type === 'group') {
|
|
29
|
+
return translate(node.expression, negate)
|
|
30
|
+
}
|
|
31
|
+
if (node.type === 'not') {
|
|
32
|
+
return translate(node.expression, !negate)
|
|
33
|
+
}
|
|
34
|
+
if (node.type === 'and' || node.type === 'or') {
|
|
35
|
+
// De Morgan: NOT (a AND b) is (NOT a) OR (NOT b), and the reverse.
|
|
36
|
+
const flippedLogic = node.type === 'and' ? 'or' : 'and'
|
|
37
|
+
const logic = negate ? flippedLogic : node.type
|
|
38
|
+
const children = [translate(node.left, negate), translate(node.right, negate)]
|
|
39
|
+
return {
|
|
40
|
+
type: 'group',
|
|
41
|
+
logic,
|
|
42
|
+
children: children.flatMap((child) =>
|
|
43
|
+
child.type === 'group' && child.logic === logic ? child.children : [child],
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const condition = (operator: FilterOperator, value: FilterValue): FilterNode => {
|
|
48
|
+
if (value === null && operator !== 'eq' && operator !== 'neq') {
|
|
49
|
+
throw new Error('Use IS NULL or IS NOT NULL to compare missing values.')
|
|
50
|
+
}
|
|
51
|
+
const field = view.fields.find((field) => field.key === node.column)
|
|
52
|
+
if (!view.filter.includes(node.column)) {
|
|
53
|
+
throw new Error(`Filtering ${node.column} is not enabled.`)
|
|
54
|
+
}
|
|
55
|
+
if (field?.kind === 'boolean' && value !== null) {
|
|
56
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
57
|
+
value = toBooleanValue(value)
|
|
58
|
+
}
|
|
59
|
+
if (typeof value !== 'boolean' || !['eq', 'neq'].includes(operator)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${node.column} requires TRUE/FALSE, 1/0, YES/NO, ON/OFF, or NULL with = or !=.`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
value !== null &&
|
|
67
|
+
field &&
|
|
68
|
+
['number', 'float', 'relation'].includes(field.kind) &&
|
|
69
|
+
(typeof value !== 'number' || !Number.isFinite(value))
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(`${node.column} requires a number.`)
|
|
72
|
+
}
|
|
73
|
+
if (value !== null && field?.kind === 'select' && !field.options?.includes(String(value))) {
|
|
74
|
+
throw new Error(`Choose a configured option for ${node.column}.`)
|
|
75
|
+
}
|
|
76
|
+
if (value !== null && field && ['date', 'datetime'].includes(field.kind)) {
|
|
77
|
+
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
|
|
78
|
+
throw new Error(`${node.column} requires a valid date.`)
|
|
79
|
+
}
|
|
80
|
+
value = field.kind === 'datetime' ? new Date(value).toISOString() : value.slice(0, 10)
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
['like', 'not_contains', 'starts_with', 'ends_with'].includes(operator) &&
|
|
84
|
+
field &&
|
|
85
|
+
!['text', 'textarea', 'email', 'url'].includes(field.kind)
|
|
86
|
+
) {
|
|
87
|
+
throw new Error(`${node.column} does not support text matching.`)
|
|
88
|
+
}
|
|
89
|
+
return { type: 'condition', field: node.column, operator, value }
|
|
90
|
+
}
|
|
91
|
+
const negative = negate !== ('isNegated' in node && node.isNegated)
|
|
92
|
+
if (node.op === 'cmp') {
|
|
93
|
+
const operators: Record<Comparison, FilterOperator> = negate
|
|
94
|
+
? {
|
|
95
|
+
'=': 'neq',
|
|
96
|
+
'!=': 'eq',
|
|
97
|
+
'>': 'lte',
|
|
98
|
+
'<': 'gte',
|
|
99
|
+
'>=': 'lt',
|
|
100
|
+
'<=': 'gt',
|
|
101
|
+
}
|
|
102
|
+
: {
|
|
103
|
+
'=': 'eq',
|
|
104
|
+
'!=': 'neq',
|
|
105
|
+
'>': 'gt',
|
|
106
|
+
'<': 'lt',
|
|
107
|
+
'>=': 'gte',
|
|
108
|
+
'<=': 'lte',
|
|
109
|
+
}
|
|
110
|
+
return condition(operators[node.comparison], node.value)
|
|
111
|
+
}
|
|
112
|
+
if (node.op === 'null') {
|
|
113
|
+
return condition(negative ? 'neq' : 'eq', null)
|
|
114
|
+
}
|
|
115
|
+
if (node.op === 'in') {
|
|
116
|
+
return {
|
|
117
|
+
type: 'group',
|
|
118
|
+
logic: negative ? 'and' : 'or',
|
|
119
|
+
children: node.list.map((value) => condition(negative ? 'neq' : 'eq', value)),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (node.op === 'between') {
|
|
123
|
+
return {
|
|
124
|
+
type: 'group',
|
|
125
|
+
logic: negative ? 'or' : 'and',
|
|
126
|
+
children: [
|
|
127
|
+
condition(negative ? 'lt' : 'gte', node.low),
|
|
128
|
+
condition(negative ? 'gt' : 'lte', node.high),
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const pattern = node.value.includes('%') ? node.value : `%${node.value}%`
|
|
133
|
+
const leading = pattern.startsWith('%')
|
|
134
|
+
const trailing = pattern.endsWith('%')
|
|
135
|
+
if (negative && (!leading || !trailing)) {
|
|
136
|
+
throw new Error("NOT LIKE supports contains patterns such as '%text%'.")
|
|
137
|
+
}
|
|
138
|
+
if (!leading && !trailing) {
|
|
139
|
+
throw new Error("Use a LIKE pattern such as '%text%', 'text%', or '%text'.")
|
|
140
|
+
}
|
|
141
|
+
const value = pattern.slice(leading ? 1 : 0, trailing ? -1 : undefined)
|
|
142
|
+
return condition(likeOperator({ negative, leading, trailing }), value)
|
|
143
|
+
}
|
|
144
|
+
if (query.columns?.some((key) => !view.columns.includes(key))) {
|
|
145
|
+
throw new Error('One or more selected columns are not enabled.')
|
|
146
|
+
}
|
|
147
|
+
if (query.order && !view.sort.includes(query.order.column)) {
|
|
148
|
+
throw new Error(`Sorting ${query.order.column} is not enabled.`)
|
|
149
|
+
}
|
|
150
|
+
const filters: FilterGroup = {
|
|
151
|
+
type: 'group',
|
|
152
|
+
logic: 'and',
|
|
153
|
+
children: query.where ? [translate(query.where)] : [],
|
|
154
|
+
}
|
|
155
|
+
const defaultSort = view.sort.includes('createdAt' as Key) ? ('createdAt' as Key) : view.sort[0]
|
|
156
|
+
return {
|
|
157
|
+
page: 1,
|
|
158
|
+
pageSize: Math.max(1, Math.min(25, query.limit ?? 25)),
|
|
159
|
+
filters,
|
|
160
|
+
sortBy: query.order?.column ?? defaultSort,
|
|
161
|
+
sortDir: sortDirection(query.order?.direction, defaultSort === 'createdAt'),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Maps a LIKE pattern's wildcard positions to the closest filter operator. */
|
|
166
|
+
function likeOperator({
|
|
167
|
+
negative,
|
|
168
|
+
leading,
|
|
169
|
+
trailing,
|
|
170
|
+
}: {
|
|
171
|
+
negative: boolean
|
|
172
|
+
leading: boolean
|
|
173
|
+
trailing: boolean
|
|
174
|
+
}) {
|
|
175
|
+
if (negative) {
|
|
176
|
+
return 'not_contains'
|
|
177
|
+
}
|
|
178
|
+
if (!leading) {
|
|
179
|
+
return 'starts_with'
|
|
180
|
+
}
|
|
181
|
+
return trailing ? 'like' : 'ends_with'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Newest first when sorting by creation date, otherwise ascending, unless the query says. */
|
|
185
|
+
function sortDirection(direction: number | undefined, isCreatedAtSort: boolean) {
|
|
186
|
+
if (direction !== undefined) {
|
|
187
|
+
return direction === 1 ? 'asc' : 'desc'
|
|
188
|
+
}
|
|
189
|
+
return isCreatedAtSort ? 'desc' : 'asc'
|
|
190
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import type { ModelView } from '../model-views'
|
|
2
|
+
import { findNamedMonthPeriods } from '../date-periods'
|
|
3
|
+
import type { Comparison, ParseResult, ResourceQuery, Scalar, Where } from './types'
|
|
4
|
+
import { toBooleanValue, createDatePeriodCondition } from './values'
|
|
5
|
+
import { toResourceTableQuery } from './filters'
|
|
6
|
+
|
|
7
|
+
const STOP = new Set(['and', 'or', 'order', 'sort', 'limit', 'not'])
|
|
8
|
+
const COMPARISONS = ['=', '==', '!=', '<>', '>', '<', '>=', '<=']
|
|
9
|
+
|
|
10
|
+
type Token = { type: 'str' | 'op' | 'word'; text: string }
|
|
11
|
+
|
|
12
|
+
function toToken(match: RegExpExecArray): Token {
|
|
13
|
+
const [, quoted, singleQuoted, doubleQuoted, operator, word] = match
|
|
14
|
+
if (quoted) {
|
|
15
|
+
// SQL escapes a quote inside a string by doubling it.
|
|
16
|
+
const text = singleQuoted?.replaceAll("''", "'") ?? doubleQuoted!.replaceAll('""', '"')
|
|
17
|
+
return { type: 'str', text }
|
|
18
|
+
}
|
|
19
|
+
if (operator) {
|
|
20
|
+
return { type: 'op', text: operator }
|
|
21
|
+
}
|
|
22
|
+
return { type: 'word', text: word! }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const comparisonAliases: Record<string, Comparison> = { '==': '=', '<>': '!=' }
|
|
26
|
+
|
|
27
|
+
function tokenize(source: string): Token[] {
|
|
28
|
+
const pattern =
|
|
29
|
+
/('((?:''|[^'])*)'|"((?:""|[^"])*)")|(>=|<=|!=|<>|==|=|>|<|\(|\)|,|\*)|([^\s'"()=<>!,]+)/y
|
|
30
|
+
const tokens: Token[] = []
|
|
31
|
+
let cursor = 0
|
|
32
|
+
while (cursor < source.length) {
|
|
33
|
+
if (/\s/.test(source[cursor]!)) {
|
|
34
|
+
cursor++
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
pattern.lastIndex = cursor
|
|
38
|
+
const match = pattern.exec(source)
|
|
39
|
+
if (!match) {
|
|
40
|
+
throw new Error(`Unexpected "${source[cursor]}"`)
|
|
41
|
+
}
|
|
42
|
+
tokens.push(toToken(match))
|
|
43
|
+
cursor = pattern.lastIndex
|
|
44
|
+
}
|
|
45
|
+
return tokens
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseResourceSql<Key extends string>(
|
|
49
|
+
source: string,
|
|
50
|
+
view: ModelView<Key>,
|
|
51
|
+
aliases: Record<string, Key> = {},
|
|
52
|
+
): ParseResult<Key> {
|
|
53
|
+
const columnsByName = Object.fromEntries(
|
|
54
|
+
view.fields.map((field) => [field.key.toLowerCase(), field.key]),
|
|
55
|
+
) as Record<string, Key>
|
|
56
|
+
const resolveColumn = (token: Token | undefined): Key => {
|
|
57
|
+
const key =
|
|
58
|
+
token && (aliases[token.text.toLowerCase()] ?? columnsByName[token.text.toLowerCase()])
|
|
59
|
+
if (!key) {
|
|
60
|
+
throw new Error(token ? `Unknown column "${token.text}"` : 'Expected a column')
|
|
61
|
+
}
|
|
62
|
+
return key
|
|
63
|
+
}
|
|
64
|
+
const entities = new Set([view.id, view.model, ...view.aliases].map((name) => name.toLowerCase()))
|
|
65
|
+
let tokens: Token[]
|
|
66
|
+
try {
|
|
67
|
+
tokens = tokenize(source)
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { error: (error as Error).message }
|
|
70
|
+
}
|
|
71
|
+
const entityAt = tokens.findIndex(
|
|
72
|
+
(token) => token.type === 'word' && entities.has(token.text.toLowerCase()),
|
|
73
|
+
)
|
|
74
|
+
if (entityAt < 0) {
|
|
75
|
+
return { error: `Expected FROM ${view.id}` }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const keyword = (token: Token | undefined) =>
|
|
79
|
+
token?.type === 'word' ? token.text.toLowerCase() : null
|
|
80
|
+
let cursor = entityAt + 1
|
|
81
|
+
const peek = () => tokens[cursor]
|
|
82
|
+
const readToken = () => tokens[cursor++]
|
|
83
|
+
const isKeyword = (...words: string[]) => words.includes(keyword(peek()) ?? '')
|
|
84
|
+
const isOperator = (text: string) => peek()?.type === 'op' && peek()!.text === text
|
|
85
|
+
|
|
86
|
+
const readValue = (): Scalar => {
|
|
87
|
+
const token = readToken()
|
|
88
|
+
if (!token) {
|
|
89
|
+
throw new Error('Expected a value')
|
|
90
|
+
}
|
|
91
|
+
if (token.type === 'str') {
|
|
92
|
+
return token.text
|
|
93
|
+
}
|
|
94
|
+
if (token.type === 'op') {
|
|
95
|
+
throw new Error(`Unexpected "${token.text}"`)
|
|
96
|
+
}
|
|
97
|
+
const word = token.text.toLowerCase()
|
|
98
|
+
if (word === 'true') {
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
if (word === 'false') {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
if (word === 'null') {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
if (!Number.isNaN(Number(token.text))) {
|
|
108
|
+
return Number(token.text)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let text = token.text
|
|
112
|
+
while (peek()?.type === 'word' && !STOP.has(keyword(peek())!)) {
|
|
113
|
+
text += ' ' + readToken()!.text
|
|
114
|
+
}
|
|
115
|
+
return text
|
|
116
|
+
}
|
|
117
|
+
const readFieldValue = (column: Key): Scalar => {
|
|
118
|
+
const parsed = readValue()
|
|
119
|
+
return view.fields.find((field) => field.key === column)?.kind === 'boolean'
|
|
120
|
+
? toBooleanValue(parsed)
|
|
121
|
+
: parsed
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const readCondition = (): Where<Key> => {
|
|
125
|
+
const token = readToken()
|
|
126
|
+
if (!token || token.type !== 'word') {
|
|
127
|
+
throw new Error(token ? `Unexpected "${token.text}"` : 'Expected a condition')
|
|
128
|
+
}
|
|
129
|
+
const column = resolveColumn(token)
|
|
130
|
+
let isNegated = false
|
|
131
|
+
const operator = peek()
|
|
132
|
+
if (!operator) {
|
|
133
|
+
throw new Error(`Expected an operator after ${token.text}`)
|
|
134
|
+
}
|
|
135
|
+
if (operator.type === 'op' && COMPARISONS.includes(operator.text)) {
|
|
136
|
+
readToken()
|
|
137
|
+
const comparison = comparisonAliases[operator.text] ?? (operator.text as Comparison)
|
|
138
|
+
const value = readFieldValue(column)
|
|
139
|
+
const kind = view.fields.find((field) => field.key === column)?.kind
|
|
140
|
+
if (typeof value === 'string' && (kind === 'date' || kind === 'datetime')) {
|
|
141
|
+
const period = findNamedMonthPeriods(value).find(
|
|
142
|
+
(period) => period.label.toLowerCase() === value.trim().toLowerCase(),
|
|
143
|
+
)
|
|
144
|
+
if (period) {
|
|
145
|
+
const boundary = (date: string) => (kind === 'date' ? date.slice(0, 10) : date)
|
|
146
|
+
return createDatePeriodCondition(
|
|
147
|
+
column,
|
|
148
|
+
comparison,
|
|
149
|
+
boundary(period.start),
|
|
150
|
+
boundary(period.end),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { type: 'cond', column, op: 'cmp', comparison, value }
|
|
155
|
+
}
|
|
156
|
+
if (isKeyword('not')) {
|
|
157
|
+
readToken()
|
|
158
|
+
isNegated = true
|
|
159
|
+
}
|
|
160
|
+
if (isKeyword('like', 'ilike', 'contains')) {
|
|
161
|
+
const word = keyword(readToken())
|
|
162
|
+
const text = String(readValue())
|
|
163
|
+
return {
|
|
164
|
+
type: 'cond',
|
|
165
|
+
column,
|
|
166
|
+
op: 'like',
|
|
167
|
+
isNegated,
|
|
168
|
+
value: word === 'contains' ? `%${text}%` : text,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (isKeyword('in')) {
|
|
172
|
+
readToken()
|
|
173
|
+
if (!isOperator('(')) {
|
|
174
|
+
throw new Error('Expected ( after IN')
|
|
175
|
+
}
|
|
176
|
+
readToken()
|
|
177
|
+
const list: Scalar[] = []
|
|
178
|
+
while (peek() && !isOperator(')')) {
|
|
179
|
+
if (isOperator(',')) {
|
|
180
|
+
readToken()
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
list.push(readFieldValue(column))
|
|
184
|
+
}
|
|
185
|
+
if (!peek()) {
|
|
186
|
+
throw new Error('Missing )')
|
|
187
|
+
}
|
|
188
|
+
readToken()
|
|
189
|
+
if (!list.length) {
|
|
190
|
+
throw new Error('IN list is empty')
|
|
191
|
+
}
|
|
192
|
+
return { type: 'cond', column, op: 'in', isNegated, list }
|
|
193
|
+
}
|
|
194
|
+
if (isKeyword('between')) {
|
|
195
|
+
readToken()
|
|
196
|
+
const low = readValue()
|
|
197
|
+
if (!isKeyword('and')) {
|
|
198
|
+
throw new Error('Expected AND in BETWEEN')
|
|
199
|
+
}
|
|
200
|
+
readToken()
|
|
201
|
+
return { type: 'cond', column, op: 'between', isNegated, low, high: readValue() }
|
|
202
|
+
}
|
|
203
|
+
if (isKeyword('is')) {
|
|
204
|
+
readToken()
|
|
205
|
+
if (isKeyword('not')) {
|
|
206
|
+
readToken()
|
|
207
|
+
isNegated = true
|
|
208
|
+
}
|
|
209
|
+
if (!isKeyword('null')) {
|
|
210
|
+
throw new Error('Expected NULL after IS')
|
|
211
|
+
}
|
|
212
|
+
readToken()
|
|
213
|
+
return { type: 'cond', column, op: 'null', isNegated }
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`Unknown operator "${peek()?.text ?? ''}"`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const readUnary = (): Where<Key> => {
|
|
219
|
+
if (isKeyword('not')) {
|
|
220
|
+
readToken()
|
|
221
|
+
return { type: 'not', expression: readUnary() }
|
|
222
|
+
}
|
|
223
|
+
if (isOperator('(')) {
|
|
224
|
+
readToken()
|
|
225
|
+
const expression = readOr()
|
|
226
|
+
if (!isOperator(')')) {
|
|
227
|
+
throw new Error('Missing )')
|
|
228
|
+
}
|
|
229
|
+
readToken()
|
|
230
|
+
return { type: 'group', expression }
|
|
231
|
+
}
|
|
232
|
+
return readCondition()
|
|
233
|
+
}
|
|
234
|
+
const readAnd = (): Where<Key> => {
|
|
235
|
+
let left = readUnary()
|
|
236
|
+
while (isKeyword('and')) {
|
|
237
|
+
readToken()
|
|
238
|
+
left = { type: 'and', left, right: readUnary() }
|
|
239
|
+
}
|
|
240
|
+
return left
|
|
241
|
+
}
|
|
242
|
+
const readOr = (): Where<Key> => {
|
|
243
|
+
let left = readAnd()
|
|
244
|
+
while (isKeyword('or')) {
|
|
245
|
+
readToken()
|
|
246
|
+
left = { type: 'or', left, right: readAnd() }
|
|
247
|
+
}
|
|
248
|
+
return left
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
let columns: Key[] | null = null
|
|
253
|
+
if (keyword(tokens[0]) === 'select') {
|
|
254
|
+
const segment = tokens.slice(1, entityAt)
|
|
255
|
+
if (keyword(segment.at(-1)) !== 'from') {
|
|
256
|
+
throw new Error(`Expected FROM before ${view.id}`)
|
|
257
|
+
}
|
|
258
|
+
const named = segment.slice(0, -1).filter((token) => token.text !== ',')
|
|
259
|
+
if (!named.length) {
|
|
260
|
+
throw new Error('Expected columns after SELECT')
|
|
261
|
+
}
|
|
262
|
+
if (!(named.length === 1 && named[0]!.text === '*')) {
|
|
263
|
+
columns = [...new Set(named.map(resolveColumn))]
|
|
264
|
+
}
|
|
265
|
+
} else if (entityAt > 0) {
|
|
266
|
+
throw new Error(`Start with SELECT or ${view.id}`)
|
|
267
|
+
}
|
|
268
|
+
if (isKeyword('where', 'with', 'whose', 'having')) {
|
|
269
|
+
readToken()
|
|
270
|
+
}
|
|
271
|
+
let where: Where<Key> | null = null
|
|
272
|
+
let order: ResourceQuery<Key>['order'] = null
|
|
273
|
+
let limit: number | null = null
|
|
274
|
+
if (peek() && !isKeyword('order', 'sort', 'limit')) {
|
|
275
|
+
where = readOr()
|
|
276
|
+
}
|
|
277
|
+
if (isKeyword('order', 'sort')) {
|
|
278
|
+
readToken()
|
|
279
|
+
if (isKeyword('by')) {
|
|
280
|
+
readToken()
|
|
281
|
+
}
|
|
282
|
+
const column = resolveColumn(readToken())
|
|
283
|
+
let direction: 1 | -1 = 1
|
|
284
|
+
if (isKeyword('asc')) {
|
|
285
|
+
readToken()
|
|
286
|
+
} else if (isKeyword('desc')) {
|
|
287
|
+
readToken()
|
|
288
|
+
direction = -1
|
|
289
|
+
}
|
|
290
|
+
order = { column, direction }
|
|
291
|
+
}
|
|
292
|
+
if (isKeyword('limit')) {
|
|
293
|
+
readToken()
|
|
294
|
+
const limitToken = readToken()
|
|
295
|
+
if (!limitToken || Number.isNaN(+limitToken.text)) {
|
|
296
|
+
throw new Error('Expected a number after LIMIT')
|
|
297
|
+
}
|
|
298
|
+
if (!Number.isFinite(+limitToken.text)) {
|
|
299
|
+
throw new Error('LIMIT requires a finite number.')
|
|
300
|
+
}
|
|
301
|
+
limit = Math.max(0, Math.floor(+limitToken.text))
|
|
302
|
+
}
|
|
303
|
+
if (peek()) {
|
|
304
|
+
throw new Error(`Unexpected "${peek()!.text}"`)
|
|
305
|
+
}
|
|
306
|
+
const query = { columns, where, order, limit }
|
|
307
|
+
toResourceTableQuery(query, view)
|
|
308
|
+
return { query }
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return { error: (error as Error).message }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface ResourceQuery<Key extends string = string> {
|
|
2
|
+
columns: Key[] | null
|
|
3
|
+
where: Where<Key> | null
|
|
4
|
+
order: { column: Key; direction: 1 | -1 } | null
|
|
5
|
+
limit: number | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type Scalar = string | number | boolean | null
|
|
9
|
+
export type Comparison = '=' | '!=' | '>' | '<' | '>=' | '<='
|
|
10
|
+
|
|
11
|
+
export type Condition<Key extends string = string> = { type: 'cond'; column: Key } & (
|
|
12
|
+
| { op: 'cmp'; comparison: Comparison; value: Scalar }
|
|
13
|
+
| { op: 'like'; isNegated: boolean; value: string }
|
|
14
|
+
| { op: 'in'; isNegated: boolean; list: Scalar[] }
|
|
15
|
+
| { op: 'between'; isNegated: boolean; low: Scalar; high: Scalar }
|
|
16
|
+
| { op: 'null'; isNegated: boolean }
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export type Where<Key extends string = string> =
|
|
20
|
+
| Condition<Key>
|
|
21
|
+
| { type: 'search'; value: string }
|
|
22
|
+
| { type: 'and'; left: Where<Key>; right: Where<Key> }
|
|
23
|
+
| { type: 'or'; left: Where<Key>; right: Where<Key> }
|
|
24
|
+
| { type: 'not'; expression: Where<Key> }
|
|
25
|
+
| { type: 'group'; expression: Where<Key> }
|
|
26
|
+
|
|
27
|
+
export type ParseResult<Key extends string = string> =
|
|
28
|
+
{ query: ResourceQuery<Key> } | { error: string }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Comparison, Condition, Scalar, Where } from './types'
|
|
2
|
+
|
|
3
|
+
export function toBooleanValue(value: Scalar): Scalar {
|
|
4
|
+
if (value === 0 || value === 1) {
|
|
5
|
+
return value === 1
|
|
6
|
+
}
|
|
7
|
+
if (typeof value === 'string' && /^(true|false|yes|no|on|off|1|0)$/i.test(value)) {
|
|
8
|
+
return /^(true|yes|on|1)$/i.test(value)
|
|
9
|
+
}
|
|
10
|
+
return value
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Compare whole date periods using an inclusive start and exclusive end. */
|
|
14
|
+
export function createDatePeriodCondition<Key extends string>(
|
|
15
|
+
column: Key,
|
|
16
|
+
comparison: Comparison,
|
|
17
|
+
start: string,
|
|
18
|
+
end: string,
|
|
19
|
+
): Where<Key> {
|
|
20
|
+
const compare = (comparison: Comparison, value: string): Condition<Key> => ({
|
|
21
|
+
type: 'cond',
|
|
22
|
+
column,
|
|
23
|
+
op: 'cmp',
|
|
24
|
+
comparison,
|
|
25
|
+
value,
|
|
26
|
+
})
|
|
27
|
+
switch (comparison) {
|
|
28
|
+
case '=':
|
|
29
|
+
return { type: 'and', left: compare('>=', start), right: compare('<', end) }
|
|
30
|
+
case '!=':
|
|
31
|
+
return { type: 'or', left: compare('<', start), right: compare('>=', end) }
|
|
32
|
+
case '>':
|
|
33
|
+
return compare('>=', end)
|
|
34
|
+
case '>=':
|
|
35
|
+
return compare('>=', start)
|
|
36
|
+
case '<':
|
|
37
|
+
return compare('<', start)
|
|
38
|
+
case '<=':
|
|
39
|
+
return compare('<', end)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ResourceQuery,
|
|
3
|
+
Scalar,
|
|
4
|
+
Comparison,
|
|
5
|
+
Condition,
|
|
6
|
+
Where,
|
|
7
|
+
ParseResult,
|
|
8
|
+
} from './query/types'
|
|
9
|
+
export { createDatePeriodCondition } from './query/values'
|
|
10
|
+
export { parseResourceSql } from './query/sql'
|
|
11
|
+
export { toResourceTableQuery } from './query/filters'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PagedResult, TableQuery } from '@cloudconfig/core/client'
|
|
2
|
+
|
|
3
|
+
export function createEmptyPage<Row>(query: TableQuery = {}): PagedResult<Row> {
|
|
4
|
+
return {
|
|
5
|
+
data: [],
|
|
6
|
+
totalCount: 0,
|
|
7
|
+
totalPages: 1,
|
|
8
|
+
page: query.page ?? 1,
|
|
9
|
+
pageSize: query.pageSize ?? 25,
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function limitPage<Row>(result: PagedResult<Row>, limit: number | null): PagedResult<Row> {
|
|
14
|
+
if (limit === null) {
|
|
15
|
+
return result
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const totalCount = Math.min(result.totalCount, limit)
|
|
19
|
+
const offset = (result.page - 1) * result.pageSize
|
|
20
|
+
return {
|
|
21
|
+
...result,
|
|
22
|
+
totalCount,
|
|
23
|
+
totalPages: Math.max(1, Math.ceil(totalCount / result.pageSize)),
|
|
24
|
+
data: result.data.slice(0, Math.max(0, limit - offset)),
|
|
25
|
+
}
|
|
26
|
+
}
|