@everystack/admin 0.4.6 → 0.4.8
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/CHANGELOG.md +34 -0
- package/package.json +12 -5
- package/src/adapters/CannotSortError.ts +19 -0
- package/src/adapters/EverystackAdapter.ts +28 -16
- package/src/adapters/types.ts +21 -6
- package/src/components/DateConditionInput.tsx +148 -0
- package/src/components/DateFilterBar.tsx +85 -0
- package/src/components/DateTimeValue.tsx +37 -0
- package/src/components/DeletePreview.tsx +15 -6
- package/src/hooks/useAdminData.ts +44 -3
- package/src/hooks/useDateRangeFilters.ts +47 -0
- package/src/hooks/useViewState.ts +41 -8
- package/src/index.ts +12 -0
- package/src/providers/AdminConfigProvider.tsx +7 -2
- package/src/providers/DataAdapterProvider.tsx +5 -3
- package/src/rpc/list-filter.ts +210 -0
- package/src/rpc/resource-dashboard-rpc.ts +62 -13
- package/src/screens/AdminRouter.tsx +19 -6
- package/src/screens/ResourceCreate.tsx +34 -5
- package/src/screens/ResourceEdit.tsx +34 -5
- package/src/screens/ResourceList.tsx +118 -17
- package/src/screens/ResourceListView.tsx +7 -3
- package/src/screens/ResourceShow.tsx +8 -4
- package/src/types.ts +107 -18
- package/src/utils/date-filter-fields.ts +32 -0
- package/src/utils/date-range.ts +146 -0
- package/src/utils/datetime-submission.ts +25 -0
- package/src/utils/field-sortable.ts +19 -0
- package/src/utils/format-cell-value.ts +23 -10
- package/src/utils/list-query.ts +3 -3
- package/src/utils/resource-query.ts +318 -0
- package/src/utils/validate-config.ts +85 -0
- package/src/utils/view-filters.ts +9 -3
- package/src/utils/wall-clock.ts +2 -1
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* Provides data fetching capabilities using the configured DataAdapter.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
|
7
|
+
import { createContext, useContext, useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
|
8
8
|
import type { DataAdapter } from '../adapters/types';
|
|
9
9
|
import type { ListParams, DataRecord } from '../types';
|
|
10
|
+
import { AdminConfigContext } from './useAdminConfig';
|
|
11
|
+
import { resourceQuerySignature, withResourceQuery } from '../utils/resource-query';
|
|
10
12
|
|
|
11
13
|
// =============================================================================
|
|
12
14
|
// Context
|
|
@@ -18,12 +20,41 @@ export interface DataAdapterContextValue {
|
|
|
18
20
|
|
|
19
21
|
export const DataAdapterContext = createContext<DataAdapterContextValue | null>(null);
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The adapter every screen calls, mapped to each resource's `table` and narrowed to its `where`.
|
|
25
|
+
* The wrapping is done here, where both contexts are read, so the order the providers are nested
|
|
26
|
+
* in cannot leave a resource's `table` or `where` quietly unapplied.
|
|
27
|
+
*/
|
|
21
28
|
export function useDataAdapter(): DataAdapter {
|
|
22
29
|
const context = useContext(DataAdapterContext);
|
|
23
|
-
|
|
30
|
+
const config = useContext(AdminConfigContext)?.config;
|
|
31
|
+
const adapter = context?.adapter;
|
|
32
|
+
// Keyed on what the wrapper reads, not on the config object: an inline config literal is a new
|
|
33
|
+
// object every render, and a new wrapper would refetch every hook keyed on the adapter.
|
|
34
|
+
// The memo runs with this render's config whenever the signature changes, and the signature
|
|
35
|
+
// covers every part of the config the wrapper reads, so `config` itself is not a dependency.
|
|
36
|
+
const signature = config ? resourceQuerySignature(config) : null;
|
|
37
|
+
const queryAdapter = useMemo(
|
|
38
|
+
() => (adapter && config ? withResourceQuery(adapter, config) : adapter),
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
[adapter, signature],
|
|
41
|
+
);
|
|
42
|
+
if (!queryAdapter) {
|
|
24
43
|
throw new Error('useDataAdapter must be used within a DataAdapterProvider');
|
|
25
44
|
}
|
|
26
|
-
return
|
|
45
|
+
return queryAdapter;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The adapter as the DataAdapterProvider holds it, without the `table` and `where` mapping. For a
|
|
50
|
+
* call that names a real table, never a resource (a delete-preview association's `table`).
|
|
51
|
+
*/
|
|
52
|
+
export function useRawDataAdapter(): DataAdapter {
|
|
53
|
+
const adapter = useContext(DataAdapterContext)?.adapter;
|
|
54
|
+
if (!adapter) {
|
|
55
|
+
throw new Error('useRawDataAdapter must be used within a DataAdapterProvider');
|
|
56
|
+
}
|
|
57
|
+
return adapter;
|
|
27
58
|
}
|
|
28
59
|
|
|
29
60
|
// =============================================================================
|
|
@@ -146,6 +177,16 @@ export function useAdminData<T extends DataRecord = DataRecord>(
|
|
|
146
177
|
const byRefetch = refetchIndex !== lastRefetchIndexRef.current;
|
|
147
178
|
lastRefetchIndexRef.current = refetchIndex;
|
|
148
179
|
if (options.skip) {
|
|
180
|
+
// A skipped query holds no result: a response in flight must not land, and rows or an
|
|
181
|
+
// error from the last query must not show once the query it belonged to is gone.
|
|
182
|
+
++seqRef.current;
|
|
183
|
+
setData(rows => (rows.length === 0 ? rows : []));
|
|
184
|
+
setTotal(0);
|
|
185
|
+
setError(null);
|
|
186
|
+
shownParamsRef.current = null;
|
|
187
|
+
setLoading(false);
|
|
188
|
+
setRefreshing(false);
|
|
189
|
+
settle(carriedRef.current.splice(0));
|
|
149
190
|
settle(queuedRef.current.splice(0));
|
|
150
191
|
return;
|
|
151
192
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { parseOwnedRange, rangeFilter, type DateRangeFilter } from '../utils/date-range';
|
|
3
|
+
|
|
4
|
+
export interface DateRangeRow {
|
|
5
|
+
field: string;
|
|
6
|
+
status: 'off' | 'ready' | 'invalid';
|
|
7
|
+
pair?: DateRangeFilter;
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UseDateRangeFiltersResult {
|
|
12
|
+
rows: DateRangeRow[];
|
|
13
|
+
/** The ready rows' bounds, merged: one `gte`/`lt` pair per field. */
|
|
14
|
+
filter?: Record<string, string[]>;
|
|
15
|
+
/** True while any row is invalid: the list fetches nothing. */
|
|
16
|
+
block: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validate each field's owned URL conditions independently of the display zone. */
|
|
20
|
+
export function useDateRangeFilters(
|
|
21
|
+
fields: readonly string[],
|
|
22
|
+
valuesOf: (field: string) => readonly string[],
|
|
23
|
+
): UseDateRangeFiltersResult {
|
|
24
|
+
const key = JSON.stringify([...new Set(fields)].map(field => [field, valuesOf(field)]));
|
|
25
|
+
return useMemo((): UseDateRangeFiltersResult => {
|
|
26
|
+
const rows: DateRangeRow[] = [];
|
|
27
|
+
const bounds: Array<[string, string[]]> = [];
|
|
28
|
+
for (const [field, values] of JSON.parse(key) as Array<[string, string[]]>) {
|
|
29
|
+
const parsed = parseOwnedRange(values);
|
|
30
|
+
if (parsed.kind === 'absent') {
|
|
31
|
+
rows.push({ field, status: 'off' });
|
|
32
|
+
} else if (parsed.kind === 'invalid') {
|
|
33
|
+
rows.push({ field, status: 'invalid', reason: parsed.reason });
|
|
34
|
+
} else {
|
|
35
|
+
const pair = { gte: parsed.gte, lt: parsed.lt };
|
|
36
|
+
rows.push({ field, status: 'ready', pair });
|
|
37
|
+
bounds.push(...Object.entries(rangeFilter(field, pair)));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
rows,
|
|
42
|
+
// fromEntries defines own properties, so a column named `__proto__` is not a prototype write.
|
|
43
|
+
...(bounds.length > 0 ? { filter: Object.fromEntries(bounds) } : {}),
|
|
44
|
+
block: rows.some(row => row.status === 'invalid'),
|
|
45
|
+
};
|
|
46
|
+
}, [key]);
|
|
47
|
+
}
|
|
@@ -12,13 +12,22 @@ import { createContext, useContext } from 'react';
|
|
|
12
12
|
/** Current query params, normalized to single strings (first value wins). */
|
|
13
13
|
export type ViewParams = Record<string, string>;
|
|
14
14
|
|
|
15
|
+
/** Every nonempty query value for each key, in URL order. */
|
|
16
|
+
export type ViewParamValues = Record<string, string[]>;
|
|
17
|
+
|
|
18
|
+
/** A delta replaces all values of a key; undefined or an empty array removes it. */
|
|
19
|
+
export type ViewParamDelta = Record<string, string | string[] | undefined>;
|
|
20
|
+
|
|
15
21
|
export interface ViewStateContextValue {
|
|
16
22
|
params: ViewParams;
|
|
23
|
+
/** All values per key; optional for contexts supplied by existing embedders. */
|
|
24
|
+
paramValues?: ViewParamValues;
|
|
17
25
|
/**
|
|
18
26
|
* Merge `next` into the current params and navigate. Keys set to undefined are
|
|
19
|
-
* removed from the URL.
|
|
27
|
+
* removed from the URL. An array replaces all values; an empty array removes
|
|
28
|
+
* the key. The path is preserved; only the query changes.
|
|
20
29
|
*/
|
|
21
|
-
setParams: (next:
|
|
30
|
+
setParams: (next: ViewParamDelta) => void;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export const ViewStateContext = createContext<ViewStateContextValue | null>(null);
|
|
@@ -28,9 +37,17 @@ export function useViewState(): ViewStateContextValue {
|
|
|
28
37
|
return useContext(ViewStateContext) ?? { params: {}, setParams: () => {} };
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
/** Read all values when available, falling back to the first-value context. */
|
|
41
|
+
export function valuesOf(view: Pick<ViewStateContextValue, 'params' | 'paramValues'>, key: string): string[] {
|
|
42
|
+
const own = (record: object | undefined, name: string): boolean => record !== undefined && Object.prototype.hasOwnProperty.call(record, name);
|
|
43
|
+
const values = own(view.paramValues, key) ? view.paramValues![key]
|
|
44
|
+
: own(view.params, key) && view.params[key] !== undefined ? [view.params[key]] : [];
|
|
45
|
+
return values.filter(value => value !== '');
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
/** Normalize a raw searchParams object (values may be arrays) to single strings. */
|
|
32
49
|
export function normalizeParams(raw?: Record<string, string | string[] | undefined>): ViewParams {
|
|
33
|
-
const out: ViewParams =
|
|
50
|
+
const out: ViewParams = Object.create(null);
|
|
34
51
|
if (!raw) return out;
|
|
35
52
|
for (const [k, v] of Object.entries(raw)) {
|
|
36
53
|
if (v == null) continue;
|
|
@@ -39,12 +56,28 @@ export function normalizeParams(raw?: Record<string, string | string[] | undefin
|
|
|
39
56
|
return out;
|
|
40
57
|
}
|
|
41
58
|
|
|
42
|
-
/**
|
|
43
|
-
export function
|
|
59
|
+
/** Keep every nonempty value; an empty key has [] so keys match normalizeParams. */
|
|
60
|
+
export function normalizeParamValues(
|
|
61
|
+
raw?: Record<string, string | (string | null | undefined)[] | null | undefined>,
|
|
62
|
+
): ViewParamValues {
|
|
63
|
+
const out: ViewParamValues = Object.create(null);
|
|
64
|
+
if (!raw) return out;
|
|
65
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
66
|
+
if (value == null) continue;
|
|
67
|
+
const values = Array.isArray(value) ? value : [value];
|
|
68
|
+
out[key] = values.filter((v): v is string => v != null && v !== '');
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Build a query string, appending each value. Empty and undefined values are dropped. */
|
|
74
|
+
export function buildQueryString(params: ViewParamDelta): string {
|
|
44
75
|
const usp = new URLSearchParams();
|
|
45
|
-
for (const [
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
for (const [key, value] of Object.entries(params)) {
|
|
77
|
+
for (const v of Array.isArray(value) ? value : [value]) {
|
|
78
|
+
if (v == null || v === '') continue;
|
|
79
|
+
usp.append(key, v);
|
|
80
|
+
}
|
|
48
81
|
}
|
|
49
82
|
const s = usp.toString();
|
|
50
83
|
return s ? `?${s}` : '';
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type {
|
|
|
12
12
|
FieldDefinition,
|
|
13
13
|
FieldDefinitionBase,
|
|
14
14
|
WallClockDatetime,
|
|
15
|
+
InstantDatetime,
|
|
15
16
|
NoTimeZone,
|
|
16
17
|
TagGroup,
|
|
17
18
|
ImageSize,
|
|
@@ -23,6 +24,8 @@ export type {
|
|
|
23
24
|
EditorComponent,
|
|
24
25
|
EditorRegistry,
|
|
25
26
|
ResourceConfig,
|
|
27
|
+
DateRangeConfig,
|
|
28
|
+
DateRangePreset,
|
|
26
29
|
ScreenLayout,
|
|
27
30
|
LayoutColumn,
|
|
28
31
|
StorageConfig,
|
|
@@ -36,6 +39,7 @@ export type {
|
|
|
36
39
|
ScopeConfig,
|
|
37
40
|
AssociationConfig,
|
|
38
41
|
DeletedMode,
|
|
42
|
+
WhereValues,
|
|
39
43
|
ListResult,
|
|
40
44
|
DataRecord,
|
|
41
45
|
AggregateFunction,
|
|
@@ -59,10 +63,12 @@ export type {
|
|
|
59
63
|
} from './adapters/types';
|
|
60
64
|
|
|
61
65
|
// EverystackAdapter
|
|
66
|
+
export { CannotSortError, isCannotSortError } from './adapters/CannotSortError';
|
|
62
67
|
export { EverystackApiError } from './adapters/EverystackApiError';
|
|
63
68
|
export { EverystackAdapter, type EverystackAdapterOptions } from './adapters/EverystackAdapter';
|
|
64
69
|
|
|
65
70
|
// Hooks
|
|
71
|
+
export { useDateRangeFilters, type UseDateRangeFiltersResult, type DateRangeRow } from './hooks/useDateRangeFilters';
|
|
66
72
|
export { useAdminConfig, type AdminConfigContextValue } from './hooks/useAdminConfig';
|
|
67
73
|
export {
|
|
68
74
|
useDataAdapter,
|
|
@@ -91,8 +97,12 @@ export {
|
|
|
91
97
|
useViewState,
|
|
92
98
|
ViewStateContext,
|
|
93
99
|
normalizeParams,
|
|
100
|
+
normalizeParamValues,
|
|
101
|
+
valuesOf,
|
|
94
102
|
buildQueryString,
|
|
95
103
|
type ViewParams,
|
|
104
|
+
type ViewParamValues,
|
|
105
|
+
type ViewParamDelta,
|
|
96
106
|
type ViewStateContextValue,
|
|
97
107
|
} from './hooks/useViewState';
|
|
98
108
|
|
|
@@ -178,6 +188,7 @@ export { ImageDisplay } from './components/ImageDisplay';
|
|
|
178
188
|
export { RowActionButton, type RowActionButtonProps } from './components/RowActionButton';
|
|
179
189
|
export { AdminLink, type AdminLinkProps } from './components/AdminLink';
|
|
180
190
|
export { FormActionBar, type FormActionBarProps } from './components/FormActionBar';
|
|
191
|
+
export { DateConditionInput, type DateConditionInputProps } from './components/DateConditionInput';
|
|
181
192
|
|
|
182
193
|
// Image utilities
|
|
183
194
|
export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
|
|
@@ -188,6 +199,7 @@ export { visibleRowActions } from './utils/row-actions';
|
|
|
188
199
|
export { enumScopes } from './utils/scopes';
|
|
189
200
|
|
|
190
201
|
// Utilities
|
|
202
|
+
export { isFieldSortable } from './utils/field-sortable';
|
|
191
203
|
export {
|
|
192
204
|
toCamelCase,
|
|
193
205
|
toSnakeCase,
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import React, { useMemo } from 'react';
|
|
8
|
+
import { TimeZoneProvider } from '@everystack/ui';
|
|
8
9
|
import type { AdminConfig } from '../types';
|
|
9
10
|
import { AdminConfigContext } from '../hooks/useAdminConfig';
|
|
10
|
-
import { validateAdminConfig } from '../utils/
|
|
11
|
+
import { validateAdminConfig } from '../utils/validate-config';
|
|
11
12
|
|
|
12
13
|
export interface AdminConfigProviderProps {
|
|
13
14
|
config: AdminConfig;
|
|
@@ -18,5 +19,9 @@ export function AdminConfigProvider({ config, children }: AdminConfigProviderPro
|
|
|
18
19
|
// Setup is where a bad config fails: loudly, naming the resource and field, not later on a
|
|
19
20
|
// screen that happens to read it.
|
|
20
21
|
useMemo(() => validateAdminConfig(config), [config]);
|
|
21
|
-
return
|
|
22
|
+
return (
|
|
23
|
+
<AdminConfigContext.Provider value={{ config }}>
|
|
24
|
+
<TimeZoneProvider timeZone={config.timeZone}>{children}</TimeZoneProvider>
|
|
25
|
+
</AdminConfigContext.Provider>
|
|
26
|
+
);
|
|
22
27
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DataAdapterProvider
|
|
3
3
|
*
|
|
4
|
-
* Provides the DataAdapter to the admin component tree.
|
|
4
|
+
* Provides the DataAdapter to the admin component tree. useDataAdapter() hands screens this adapter
|
|
5
|
+
* mapped to the config's `table` and `where` (withResourceQuery), whichever way the providers are nested.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import React from 'react';
|
|
8
|
+
import React, { useMemo } from 'react';
|
|
8
9
|
import type { DataAdapter } from '../adapters/types';
|
|
9
10
|
import { DataAdapterContext } from '../hooks/useAdminData';
|
|
10
11
|
|
|
@@ -14,5 +15,6 @@ export interface DataAdapterProviderProps {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function DataAdapterProvider({ adapter, children }: DataAdapterProviderProps) {
|
|
17
|
-
|
|
18
|
+
const value = useMemo(() => ({ adapter }), [adapter]);
|
|
19
|
+
return <DataAdapterContext.Provider value={value}>{children}</DataAdapterContext.Provider>;
|
|
18
20
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { sql, type SQL } from 'drizzle-orm';
|
|
2
|
+
import { HandlerError } from '@everystack/api/errors';
|
|
3
|
+
import { toCamelCase } from '../utils/naming';
|
|
4
|
+
import type { ResourceDashboardConfig } from '../types';
|
|
5
|
+
|
|
6
|
+
// Keep recognition in step with EverystackAdapter's POSTGREST_OPERATORS. Recognized
|
|
7
|
+
// but unsupported operators must refuse, rather than turn into plain equality.
|
|
8
|
+
const KNOWN_OPERATORS = new Set([
|
|
9
|
+
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
|
|
10
|
+
'like', 'ilike', 'is', 'in', 'not', 'cs', 'cd',
|
|
11
|
+
'sl', 'sr', 'nxl', 'nxr', 'adj', 'ov', 'fts',
|
|
12
|
+
]);
|
|
13
|
+
const OPERATORS = new Map([
|
|
14
|
+
['eq', '='], ['neq', '<>'], ['gt', '>'], ['gte', '>='], ['lt', '<'], ['lte', '<='],
|
|
15
|
+
]);
|
|
16
|
+
const ERROR_COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
17
|
+
const UTC_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?Z$/;
|
|
18
|
+
const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
19
|
+
const MAX_MEMBERS = 25;
|
|
20
|
+
const MAX_MEMBER_LENGTH = 256;
|
|
21
|
+
const MAX_MEMBERSHIP_LENGTH = MAX_MEMBERS * MAX_MEMBER_LENGTH + MAX_MEMBERS - 1 + 2;
|
|
22
|
+
|
|
23
|
+
interface FilterColumns {
|
|
24
|
+
exact: Set<string>;
|
|
25
|
+
search: Set<string>;
|
|
26
|
+
dimensions: Set<string>;
|
|
27
|
+
aliases: Map<string, string | null>;
|
|
28
|
+
allowedValues: Map<string, Set<string>>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const columnsByConfig = new WeakMap<ResourceDashboardConfig, FilterColumns>();
|
|
32
|
+
|
|
33
|
+
function filterColumns(config: ResourceDashboardConfig): FilterColumns {
|
|
34
|
+
const cached = columnsByConfig.get(config);
|
|
35
|
+
if (cached) return cached;
|
|
36
|
+
const exact = new Set([
|
|
37
|
+
...config.listColumns, config.timestampColumn, ...config.dimensions.map(dim => dim.column),
|
|
38
|
+
]);
|
|
39
|
+
const search = new Set(config.searchColumns ?? []);
|
|
40
|
+
const dimensions = new Set(config.dimensions.map(dim => dim.column));
|
|
41
|
+
const aliases = new Map<string, string | null>();
|
|
42
|
+
for (const column of new Set([...exact, ...search])) {
|
|
43
|
+
const alias = toCamelCase(column);
|
|
44
|
+
aliases.set(alias, aliases.has(alias) ? null : column);
|
|
45
|
+
}
|
|
46
|
+
const allowedValues = new Map<string, Set<string>>();
|
|
47
|
+
for (const dim of config.dimensions) {
|
|
48
|
+
if (dim.allowedValues?.length) allowedValues.set(dim.column, new Set(dim.allowedValues));
|
|
49
|
+
}
|
|
50
|
+
const result = { exact, search, dimensions, aliases, allowedValues };
|
|
51
|
+
columnsByConfig.set(config, result);
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function refuse(message: string, column?: string): never {
|
|
56
|
+
// Never pass an unsafe caller name to HandlerError's metadata constructor.
|
|
57
|
+
throw new HandlerError(400, message, {
|
|
58
|
+
code: 'invalid_filter',
|
|
59
|
+
...(column !== undefined && ERROR_COLUMN.exec(column)?.[0] === column ? { column } : {}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isUtcInstant(value: string): boolean {
|
|
64
|
+
const match = UTC_INSTANT.exec(value);
|
|
65
|
+
if (!match) return false;
|
|
66
|
+
// PostgreSQL has no year 0000; refuse it here rather than let the database answer 422.
|
|
67
|
+
if (Number(match[1]) < 1) return false;
|
|
68
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
69
|
+
const parts = [year, month, day, hour, minute, second].map(Number);
|
|
70
|
+
const [y, m, d, h, min, s] = parts;
|
|
71
|
+
// Date.UTC treats years 0..99 as 1900..1999. A 400-year shift preserves leap
|
|
72
|
+
// years, then setUTCFullYear restores the requested year without that coercion.
|
|
73
|
+
const date = new Date(Date.UTC(y < 100 ? y + 400 : y, m - 1, d, h, min, s));
|
|
74
|
+
if (y < 100) date.setUTCFullYear(y);
|
|
75
|
+
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 &&
|
|
76
|
+
date.getUTCDate() === d && date.getUTCHours() === h &&
|
|
77
|
+
date.getUTCMinutes() === min && date.getUTCSeconds() === s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
81
|
+
if (value === null || typeof value !== 'object') return false;
|
|
82
|
+
const prototype = Object.getPrototypeOf(value);
|
|
83
|
+
if (prototype === null) return true;
|
|
84
|
+
// Request.json() can return another realm's Object.prototype (also in Jest).
|
|
85
|
+
const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')?.value;
|
|
86
|
+
return Object.getPrototypeOf(prototype) === null && typeof constructor === 'function' &&
|
|
87
|
+
Function.prototype.toString.call(constructor) === Function.prototype.toString.call(Object);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isWellFormed(value: string): boolean {
|
|
91
|
+
const check = (String.prototype as typeof String.prototype & {
|
|
92
|
+
isWellFormed?: (this: string) => boolean;
|
|
93
|
+
}).isWellFormed;
|
|
94
|
+
return check ? check.call(value) : !LONE_SURROGATE.test(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Translate the adapter's `*` pattern to SQL LIKE with `ESCAPE '!'`. */
|
|
98
|
+
export function ilikePattern(raw: string): string | null {
|
|
99
|
+
if (raw.length > 256 || raw.includes('\0') || !isWellFormed(raw)) return null;
|
|
100
|
+
let pattern = '';
|
|
101
|
+
let wildcards = 0;
|
|
102
|
+
for (let i = 0; i < raw.length; i++) {
|
|
103
|
+
let char = raw[i];
|
|
104
|
+
if (char === '\\') {
|
|
105
|
+
if (i + 1 < raw.length) char = raw[++i];
|
|
106
|
+
} else if (char === '*') {
|
|
107
|
+
if (++wildcards > 8) return null;
|
|
108
|
+
pattern += '%';
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
pattern += char === '!' || char === '%' || char === '_' ? `!${char}` : char;
|
|
112
|
+
}
|
|
113
|
+
return pattern;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseCondition(raw: unknown, column: string): { operator: string; value: string } {
|
|
117
|
+
// The adapter sends a number or boolean as its String() text; mirror that, and refuse the rest.
|
|
118
|
+
const text = typeof raw === 'string' ? raw
|
|
119
|
+
: typeof raw === 'boolean' || (typeof raw === 'number' && Number.isFinite(raw)) ? String(raw)
|
|
120
|
+
: undefined;
|
|
121
|
+
if (text === undefined) refuse('Filter values must be strings, numbers or booleans', column);
|
|
122
|
+
if (text.includes('\0')) refuse('Invalid filter value', column);
|
|
123
|
+
// Same rule as the adapter: only an exact known-operator prefix is an operator. Any other
|
|
124
|
+
// dotted text (`example.com`, `cs2.log`, `gte;.x`) is a plain value, bound as a parameter.
|
|
125
|
+
let operator = 'eq';
|
|
126
|
+
let value = text;
|
|
127
|
+
const dot = text.indexOf('.');
|
|
128
|
+
if (dot > 0) {
|
|
129
|
+
const prefix = text.slice(0, dot);
|
|
130
|
+
if (KNOWN_OPERATORS.has(prefix)) {
|
|
131
|
+
if (!OPERATORS.has(prefix) && prefix !== 'ilike' && prefix !== 'in') refuse('Unsupported filter operator', column);
|
|
132
|
+
operator = prefix;
|
|
133
|
+
value = text.slice(dot + 1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (value.length > (operator === 'in' ? MAX_MEMBERSHIP_LENGTH : 256)) refuse('Filter value is too long', column);
|
|
137
|
+
if (!isWellFormed(value)) refuse('Invalid filter value', column);
|
|
138
|
+
return { operator, value };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function membershipValues(value: string, column: string): string[] {
|
|
142
|
+
if (!value.startsWith('(') || !value.endsWith(')')) refuse('Invalid filter membership', column);
|
|
143
|
+
const body = value.slice(1, -1);
|
|
144
|
+
// The native mapper emits simple comma lists. Refuse quoted, escaped or nested
|
|
145
|
+
// forms rather than interpret a quoted comma as two different members.
|
|
146
|
+
if (/[()"\\]/.test(body)) refuse('Invalid filter membership', column);
|
|
147
|
+
// parseCondition bounds the entire list before this allocation.
|
|
148
|
+
const members = body.split(',');
|
|
149
|
+
if (members.length > MAX_MEMBERS || members.some(member => member.length === 0 || member.length > MAX_MEMBER_LENGTH)) {
|
|
150
|
+
refuse('Invalid filter membership', column);
|
|
151
|
+
}
|
|
152
|
+
return members;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Compile list-protocol conditions using only factory-validated config identifiers. */
|
|
156
|
+
export function compileListFilter(
|
|
157
|
+
config: ResourceDashboardConfig,
|
|
158
|
+
filter: unknown,
|
|
159
|
+
): { conditions: SQL[]; timeBounded: boolean } {
|
|
160
|
+
const columns = filterColumns(config);
|
|
161
|
+
const conditions: SQL[] = [];
|
|
162
|
+
let timeBounded = false;
|
|
163
|
+
if (filter == null) return { conditions, timeBounded };
|
|
164
|
+
if (!isPlainObject(filter)) {
|
|
165
|
+
refuse('Filter must be a plain object');
|
|
166
|
+
}
|
|
167
|
+
const countByColumn = new Map<string, number>();
|
|
168
|
+
for (const [name, raw] of Object.entries(filter)) {
|
|
169
|
+
const column = columns.exact.has(name) || columns.search.has(name) ? name : columns.aliases.get(name);
|
|
170
|
+
if (column === null) refuse('Ambiguous filter column', name);
|
|
171
|
+
if (column === undefined) refuse('Unknown filter column', name);
|
|
172
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
173
|
+
if (values.length === 0) refuse('Filter conditions must not be empty', name);
|
|
174
|
+
const count = (countByColumn.get(column) ?? 0) + values.length;
|
|
175
|
+
if (count > 10 || conditions.length + values.length > 50) refuse('Too many filter conditions', name);
|
|
176
|
+
countByColumn.set(column, count);
|
|
177
|
+
for (const rawValue of values) {
|
|
178
|
+
if (rawValue == null) continue; // the adapter skips null and undefined
|
|
179
|
+
const { operator, value } = parseCondition(rawValue, name);
|
|
180
|
+
if (operator === 'in') {
|
|
181
|
+
if (!columns.dimensions.has(column)) refuse('Column does not support membership filters', name);
|
|
182
|
+
const members = membershipValues(value, name);
|
|
183
|
+
const allowed = columns.allowedValues.get(column);
|
|
184
|
+
if (allowed && members.some(member => !allowed.has(member))) refuse('Invalid filter dimension value', name);
|
|
185
|
+
if (column === config.timestampColumn && members.some(member => !isUtcInstant(member))) {
|
|
186
|
+
refuse('Filter timestamp must be a valid UTC instant', name);
|
|
187
|
+
}
|
|
188
|
+
conditions.push(sql`${sql.raw(column)} IN (${sql.join(members.map(member => sql`${member}`), sql`, `)})`);
|
|
189
|
+
continue; // Membership does not replace the default time window.
|
|
190
|
+
}
|
|
191
|
+
if (operator === 'ilike') {
|
|
192
|
+
if (!columns.search.has(column)) refuse('Column does not support ilike filters', name);
|
|
193
|
+
const pattern = ilikePattern(value);
|
|
194
|
+
if (pattern === null) refuse('Invalid filter pattern', name);
|
|
195
|
+
conditions.push(sql`${sql.raw(column)} ILIKE ${pattern} ESCAPE '!'`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (!columns.exact.has(column)) refuse('Unknown filter column', name);
|
|
199
|
+
if (column === config.timestampColumn && !isUtcInstant(value)) refuse('Filter timestamp must be a valid UTC instant', name);
|
|
200
|
+
const allowed = columns.allowedValues.get(column);
|
|
201
|
+
if ((operator === 'eq' || operator === 'neq') && allowed && !allowed.has(value)) {
|
|
202
|
+
refuse('Invalid filter dimension value', name);
|
|
203
|
+
}
|
|
204
|
+
// No cast: PostgreSQL infers the parameter type from the configured column.
|
|
205
|
+
conditions.push(sql`${sql.raw(column)} ${sql.raw(OPERATORS.get(operator)!)} ${value}`);
|
|
206
|
+
if (column === config.timestampColumn && operator !== 'neq') timeBounded = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { conditions, timeBounded };
|
|
210
|
+
}
|