@rebasepro/common 0.8.0 → 0.9.0
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/README.md +4 -4
- package/dist/collections/CollectionRegistry.d.ts +16 -16
- package/dist/collections/default-collections.d.ts +1 -1
- package/dist/data/buildRebaseData.d.ts +30 -2
- package/dist/data/buildRoutedRebaseData.d.ts +14 -9
- package/dist/data/filter-dialect.d.ts +18 -4
- package/dist/data/query_builder.d.ts +1 -1
- package/dist/data/resolveDataSource.d.ts +1 -1
- package/dist/data/sort-dialect.d.ts +41 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +569 -159
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +573 -163
- package/dist/index.umd.js.map +1 -1
- package/dist/util/builders.d.ts +19 -56
- package/dist/util/callbacks.d.ts +3 -3
- package/dist/util/collections.d.ts +4 -4
- package/dist/util/entities.d.ts +2 -2
- package/dist/util/filter-operator-resolution.d.ts +32 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/navigation_from_path.d.ts +4 -4
- package/dist/util/navigation_utils.d.ts +3 -3
- package/dist/util/parent_references_from_path.d.ts +2 -2
- package/dist/util/permissions.d.ts +6 -6
- package/dist/util/policy/policyToPostgres.d.ts +14 -2
- package/dist/util/references.d.ts +2 -2
- package/dist/util/relations.d.ts +5 -5
- package/dist/util/resolutions.d.ts +2 -2
- package/package.json +3 -3
- package/src/collections/CollectionRegistry.ts +36 -36
- package/src/data/buildRebaseData.ts +332 -57
- package/src/data/buildRoutedRebaseData.ts +22 -16
- package/src/data/filter-dialect.ts +145 -60
- package/src/data/query_builder.ts +11 -2
- package/src/data/resolveDataSource.ts +1 -1
- package/src/data/sort-dialect.ts +56 -0
- package/src/index.ts +1 -0
- package/src/util/builders.ts +25 -99
- package/src/util/callbacks.ts +8 -8
- package/src/util/collections.ts +4 -4
- package/src/util/entities.ts +4 -4
- package/src/util/filter-operator-resolution.ts +81 -0
- package/src/util/index.ts +1 -0
- package/src/util/navigation_from_path.ts +4 -4
- package/src/util/navigation_utils.ts +8 -8
- package/src/util/parent_references_from_path.ts +3 -3
- package/src/util/permissions.test.ts +2 -2
- package/src/util/permissions.ts +7 -7
- package/src/util/policy/evaluatePolicy.ts +6 -0
- package/src/util/policy/policyToPostgres.ts +90 -10
- package/src/util/references.ts +2 -2
- package/src/util/relations.ts +12 -12
- package/src/util/resolutions.ts +5 -5
package/src/util/collections.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DefaultSelectedViewBuilder,
|
|
3
3
|
DefaultSelectedViewParams,
|
|
4
|
-
|
|
4
|
+
CollectionConfig,
|
|
5
5
|
Properties,
|
|
6
6
|
Property
|
|
7
7
|
} from "@rebasepro/types";
|
|
@@ -99,7 +99,7 @@ export function resolveDefaultSelectedView(
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
export function getLocalChangesBackup(collection:
|
|
102
|
+
export function getLocalChangesBackup(collection: CollectionConfig) {
|
|
103
103
|
if (!collection.localChangesBackup) {
|
|
104
104
|
return "manual_apply";
|
|
105
105
|
}
|
|
@@ -108,12 +108,12 @@ export function getLocalChangesBackup(collection: EntityCollection) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
* Returns the primary keys for
|
|
111
|
+
* Returns the primary keys for a entity collection by inspecting the properties
|
|
112
112
|
* and finding any properties with `isId`.
|
|
113
113
|
* Fallbacks to `["id"]` if no properties are marked as `isId: true`.
|
|
114
114
|
* @param collection
|
|
115
115
|
*/
|
|
116
|
-
export function getPrimaryKeys<M extends Record<string, unknown>>(collection:
|
|
116
|
+
export function getPrimaryKeys<M extends Record<string, unknown>>(collection: CollectionConfig<M>): Extract<keyof M, string>[] {
|
|
117
117
|
const properties = collection.properties;
|
|
118
118
|
if (!properties) {
|
|
119
119
|
return ["id"] as Extract<keyof M, string>[];
|
package/src/util/entities.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function getDefaultValueFortype(type: DataType): unknown {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Update the automatic values in
|
|
84
|
+
* Update the automatic values in a entity before save
|
|
85
85
|
* @group Driver
|
|
86
86
|
*/
|
|
87
87
|
export function updateDateAutoValues<M extends Record<string, unknown>>({
|
|
@@ -117,7 +117,7 @@ export function updateDateAutoValues<M extends Record<string, unknown>>({
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* Add missing required fields, expected in the collection, to the values of
|
|
120
|
+
* Add missing required fields, expected in the collection, to the values of a entity
|
|
121
121
|
* @param values
|
|
122
122
|
* @param properties
|
|
123
123
|
* @group Driver
|
|
@@ -148,7 +148,7 @@ export function getReferenceFrom<M extends Record<string, unknown>>(entity: Enti
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
export function getRelationFrom<M extends Record<string, unknown>>(entity: Entity<M>): EntityRelation {
|
|
151
|
-
return new EntityRelation(entity.id, entity.path, entity);
|
|
151
|
+
return new EntityRelation(entity.id, entity.path, entity as unknown as Record<string, unknown>);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/**
|
|
@@ -179,7 +179,7 @@ export function normalizeToEntityRelation(value: unknown, propertyType?: string)
|
|
|
179
179
|
return new EntityRelation(
|
|
180
180
|
obj.id as string | number,
|
|
181
181
|
obj.path as string,
|
|
182
|
-
obj.data as
|
|
182
|
+
obj.data as Record<string, unknown> | undefined
|
|
183
183
|
);
|
|
184
184
|
}
|
|
185
185
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ALL_WHERE_FILTER_OPS,
|
|
3
|
+
DataType,
|
|
4
|
+
getDataSourceCapabilities,
|
|
5
|
+
Property,
|
|
6
|
+
WhereFilterOp
|
|
7
|
+
} from "@rebasepro/types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default operators offered per property type, before engine capabilities and
|
|
11
|
+
* per-property narrowing are applied. These mirror what the built-in filter
|
|
12
|
+
* fields can render.
|
|
13
|
+
*/
|
|
14
|
+
const COMPARISON_OPS: readonly WhereFilterOp[] = ["==", "!=", ">", ">=", "<", "<="];
|
|
15
|
+
const NULL_CHECK_OPS: readonly WhereFilterOp[] = ["is-null", "is-not-null"];
|
|
16
|
+
const MEMBERSHIP_OPS: readonly WhereFilterOp[] = ["in", "not-in"];
|
|
17
|
+
const PATTERN_OPS: readonly WhereFilterOp[] = ["like", "ilike", "not-like", "not-ilike"];
|
|
18
|
+
|
|
19
|
+
const DEFAULT_OPS_BY_TYPE: Partial<Record<DataType, readonly WhereFilterOp[]>> = {
|
|
20
|
+
string: [...COMPARISON_OPS, ...MEMBERSHIP_OPS, ...PATTERN_OPS, ...NULL_CHECK_OPS],
|
|
21
|
+
number: [...COMPARISON_OPS, ...MEMBERSHIP_OPS, ...NULL_CHECK_OPS],
|
|
22
|
+
date: [...COMPARISON_OPS, ...NULL_CHECK_OPS],
|
|
23
|
+
boolean: ["==", "!=", ...NULL_CHECK_OPS],
|
|
24
|
+
reference: ["==", "!=", ...MEMBERSHIP_OPS, ...NULL_CHECK_OPS],
|
|
25
|
+
relation: ["==", "!=", ...MEMBERSHIP_OPS, ...NULL_CHECK_OPS]
|
|
26
|
+
// geopoint, map, vector, binary, array (as a container): not filterable
|
|
27
|
+
// through the generic filter UI.
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Operators offered when the property is an *array of* a filterable type. */
|
|
31
|
+
const ARRAY_OPS: readonly WhereFilterOp[] = ["array-contains", "array-contains-any"];
|
|
32
|
+
|
|
33
|
+
export interface ResolveFilterOperatorsParams {
|
|
34
|
+
/**
|
|
35
|
+
* The property to filter on. For array properties, pass the **item**
|
|
36
|
+
* property (`property.of`) together with `isArray: true` — the same
|
|
37
|
+
* convention the filter field dispatchers use.
|
|
38
|
+
*/
|
|
39
|
+
property: Property;
|
|
40
|
+
/** True when filtering an array of `property`. */
|
|
41
|
+
isArray?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The engine backing the collection (`collection.engine`, e.g.
|
|
44
|
+
* `"postgres"`, `"firestore"`). Falls back to the default engine's
|
|
45
|
+
* capabilities when omitted.
|
|
46
|
+
*/
|
|
47
|
+
engine?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve which filter operators the UI should offer for a property.
|
|
52
|
+
*
|
|
53
|
+
* The result is the **intersection** of three sets:
|
|
54
|
+
* 1. what the engine can execute — {@link DataSourceCapabilities.filterOperators}
|
|
55
|
+
* (e.g. Firestore cannot run the LIKE family);
|
|
56
|
+
* 2. what makes sense for the property type (e.g. no `>` on booleans);
|
|
57
|
+
* 3. the developer's optional narrowing — `property.ui.filterOperators`.
|
|
58
|
+
*
|
|
59
|
+
* Returns an empty array when the property is not filterable (either by
|
|
60
|
+
* type, or because the developer disabled it with `filterOperators: []`).
|
|
61
|
+
*
|
|
62
|
+
* @group Models
|
|
63
|
+
*/
|
|
64
|
+
export function resolveFilterOperators({
|
|
65
|
+
property,
|
|
66
|
+
isArray,
|
|
67
|
+
engine
|
|
68
|
+
}: ResolveFilterOperatorsParams): WhereFilterOp[] {
|
|
69
|
+
const typeDefaults: readonly WhereFilterOp[] = isArray
|
|
70
|
+
? ARRAY_OPS
|
|
71
|
+
: DEFAULT_OPS_BY_TYPE[property.type] ?? [];
|
|
72
|
+
if (typeDefaults.length === 0) return [];
|
|
73
|
+
|
|
74
|
+
const engineOps = new Set(getDataSourceCapabilities(engine).filterOperators ?? ALL_WHERE_FILTER_OPS);
|
|
75
|
+
|
|
76
|
+
const narrowing = property.ui?.filterOperators;
|
|
77
|
+
const narrowingSet = narrowing !== undefined ? new Set(narrowing) : undefined;
|
|
78
|
+
|
|
79
|
+
return typeDefaults.filter(op =>
|
|
80
|
+
engineOps.has(op) && (narrowingSet === undefined || narrowingSet.has(op)));
|
|
81
|
+
}
|
package/src/util/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
2
|
type EntityCustomView<M extends Record<string, unknown> = Record<string, unknown>> = { key: string; [key: string]: unknown };
|
|
3
3
|
import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
|
|
4
4
|
import { getSubcollections } from "./resolutions";
|
|
@@ -13,7 +13,7 @@ export interface NavigationViewEntityInternal<M extends Record<string, unknown>>
|
|
|
13
13
|
entityId: string | number;
|
|
14
14
|
slug: string;
|
|
15
15
|
path: string;
|
|
16
|
-
parentCollection:
|
|
16
|
+
parentCollection: CollectionConfig<M>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export interface NavigationViewCollectionInternal<M extends Record<string, unknown>> {
|
|
@@ -21,7 +21,7 @@ export interface NavigationViewCollectionInternal<M extends Record<string, unkno
|
|
|
21
21
|
id: string;
|
|
22
22
|
slug: string;
|
|
23
23
|
path: string;
|
|
24
|
-
collection:
|
|
24
|
+
collection: CollectionConfig<M>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export interface NavigationViewEntityCustomInternal<M extends Record<string, unknown>> {
|
|
@@ -34,7 +34,7 @@ export interface NavigationViewEntityCustomInternal<M extends Record<string, unk
|
|
|
34
34
|
|
|
35
35
|
export function getNavigationEntriesFromPath(props: {
|
|
36
36
|
path: string,
|
|
37
|
-
collections:
|
|
37
|
+
collections: CollectionConfig[] | undefined,
|
|
38
38
|
currentFullPath?: string,
|
|
39
39
|
contextEntityViews?: EntityCustomView[]
|
|
40
40
|
}): NavigationViewInternal[] {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
2
|
|
|
3
3
|
import { getSubcollections } from "./resolutions";
|
|
4
4
|
|
|
@@ -33,13 +33,13 @@ export function getLastSegment(path: string) {
|
|
|
33
33
|
return cleanPath;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function resolveCollectionPathIds(path: string, allCollections:
|
|
36
|
+
export function resolveCollectionPathIds(path: string, allCollections: CollectionConfig[]): string {
|
|
37
37
|
let remainingPath = removeInitialAndTrailingSlashes(path);
|
|
38
38
|
if (!remainingPath) {
|
|
39
39
|
return "";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
let currentCollections:
|
|
42
|
+
let currentCollections: CollectionConfig[] | undefined = allCollections;
|
|
43
43
|
const resolvedPathParts: string[] = [];
|
|
44
44
|
|
|
45
45
|
while (remainingPath.length > 0) {
|
|
@@ -53,7 +53,7 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
|
|
|
53
53
|
|
|
54
54
|
let foundMatch = false;
|
|
55
55
|
// Sort potential matches by length descending to prioritize longer matches (e.g., "a/b" over "a")
|
|
56
|
-
const potentialMatches: { col:
|
|
56
|
+
const potentialMatches: { col: CollectionConfig; match: string; }[] = currentCollections
|
|
57
57
|
.flatMap(col => [{
|
|
58
58
|
col,
|
|
59
59
|
match: col.slug
|
|
@@ -76,7 +76,7 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
|
|
|
76
76
|
break; // Path ends with a collection segment
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
// The next segment must be
|
|
79
|
+
// The next segment must be a entity ID
|
|
80
80
|
const idSeparatorIndex = remainingPath.indexOf("/");
|
|
81
81
|
let entityId: string | number;
|
|
82
82
|
if (idSeparatorIndex > -1) {
|
|
@@ -87,7 +87,7 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
|
|
|
87
87
|
// but handle it defensively: assume the rest is the ID
|
|
88
88
|
entityId = remainingPath;
|
|
89
89
|
remainingPath = "";
|
|
90
|
-
console.warn(`resolveCollectionPathIds: Path seems to end with
|
|
90
|
+
console.warn(`resolveCollectionPathIds: Path seems to end with a entity ID "${entityId}" instead of a collection segment in original path "${path}". This might indicate an invalid input path.`);
|
|
91
91
|
// Even if it ends here, we still need to push the ID
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -123,7 +123,7 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
|
|
|
123
123
|
* @param slugOrPath
|
|
124
124
|
* @param collections
|
|
125
125
|
*/
|
|
126
|
-
export function getCollectionBySlugWithin(slugOrPath: string, collections:
|
|
126
|
+
export function getCollectionBySlugWithin(slugOrPath: string, collections: CollectionConfig[]): CollectionConfig | undefined {
|
|
127
127
|
|
|
128
128
|
const subpaths = removeInitialAndTrailingSlashes(slugOrPath).split("/");
|
|
129
129
|
if (subpaths.length % 2 === 0) {
|
|
@@ -131,7 +131,7 @@ export function getCollectionBySlugWithin(slugOrPath: string, collections: Entit
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
const subpathCombinations = getCollectionPathsCombinations(subpaths);
|
|
134
|
-
let result:
|
|
134
|
+
let result: CollectionConfig | undefined;
|
|
135
135
|
for (let i = 0; i < subpathCombinations.length; i++) {
|
|
136
136
|
const subpathCombination = subpathCombinations[i];
|
|
137
137
|
const navigationEntry = collections && collections
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CollectionConfig, EntityReference } from "@rebasepro/types";
|
|
2
2
|
import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
|
|
3
3
|
import { getSubcollections } from "./resolutions";
|
|
4
4
|
|
|
5
5
|
export function getParentReferencesFromPath(props: {
|
|
6
6
|
path: string,
|
|
7
|
-
collections:
|
|
7
|
+
collections: CollectionConfig[] | undefined,
|
|
8
8
|
currentFullPath?: string,
|
|
9
9
|
}): EntityReference[] {
|
|
10
10
|
|
|
@@ -21,7 +21,7 @@ export function getParentReferencesFromPath(props: {
|
|
|
21
21
|
for (let i = 0; i < subpathCombinations.length; i++) {
|
|
22
22
|
const subpathCombination = subpathCombinations[i];
|
|
23
23
|
|
|
24
|
-
const collection:
|
|
24
|
+
const collection: CollectionConfig | undefined = collections && collections.find((entry) => entry.slug === subpathCombination);
|
|
25
25
|
|
|
26
26
|
// If we find a collection, we add the reference and continue
|
|
27
27
|
if (collection) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canCreateEntity, canEditEntity, canDeleteEntity, canReadCollection } from "./permissions";
|
|
2
|
-
import {
|
|
2
|
+
import { CollectionConfig, AuthController, Entity, User, SecurityRule } from "@rebasepro/types";
|
|
3
3
|
|
|
4
4
|
describe("Permissions Evaluator", () => {
|
|
5
5
|
|
|
@@ -46,7 +46,7 @@ describe("Permissions Evaluator", () => {
|
|
|
46
46
|
user: null
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
const createMockCollection = (rules?: SecurityRule[]):
|
|
49
|
+
const createMockCollection = (rules?: SecurityRule[]): CollectionConfig => ({
|
|
50
50
|
slug: "test",
|
|
51
51
|
name: "Test",
|
|
52
52
|
table: "test",
|
package/src/util/permissions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Entity,
|
|
1
|
+
import { Entity, CollectionConfig, getDataSourceCapabilities, SecurityOperation, SecurityRule, User } from "@rebasepro/types";
|
|
2
2
|
import { securityRuleToConditions } from "./policy/securityRuleToConditions";
|
|
3
3
|
import { evaluatePolicy, PolicyEvalContext, TriState } from "./policy/evaluatePolicy";
|
|
4
4
|
|
|
@@ -35,7 +35,7 @@ function kleeneAnd(values: TriState[]): TriState {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** The operations a rule covers, mirroring the Postgres generator's resolution. */
|
|
38
|
-
function ruleOperations(rule: SecurityRule): SecurityOperation[] {
|
|
38
|
+
function ruleOperations(rule: SecurityRule): readonly SecurityOperation[] {
|
|
39
39
|
return rule.operations && rule.operations.length > 0
|
|
40
40
|
? rule.operations
|
|
41
41
|
: [rule.operation ?? "all"];
|
|
@@ -83,7 +83,7 @@ function resolveTriState(value: TriState, onUnknown: UnknownResolution): boolean
|
|
|
83
83
|
* for optimistic UI gating; enforcement callers should pass `"deny"`.
|
|
84
84
|
*/
|
|
85
85
|
export function checkOperation<M extends Record<string, unknown>, USER extends User>(
|
|
86
|
-
collection:
|
|
86
|
+
collection: CollectionConfig<M>,
|
|
87
87
|
authContext: AuthContext<USER>,
|
|
88
88
|
entity: Entity<M> | null,
|
|
89
89
|
targetOperation: SecurityOperation,
|
|
@@ -129,7 +129,7 @@ export function checkOperation<M extends Record<string, unknown>, USER extends U
|
|
|
129
129
|
|
|
130
130
|
export function canReadCollection<M extends Record<string, unknown>, USER extends User>
|
|
131
131
|
(
|
|
132
|
-
collection:
|
|
132
|
+
collection: CollectionConfig<M>,
|
|
133
133
|
authContext: AuthContext<USER>
|
|
134
134
|
): boolean {
|
|
135
135
|
return checkOperation(collection, authContext, null, "select");
|
|
@@ -137,7 +137,7 @@ export function canReadCollection<M extends Record<string, unknown>, USER extend
|
|
|
137
137
|
|
|
138
138
|
export function canEditEntity<M extends Record<string, unknown>, USER extends User>
|
|
139
139
|
(
|
|
140
|
-
collection:
|
|
140
|
+
collection: CollectionConfig<M>,
|
|
141
141
|
authContext: AuthContext<USER>,
|
|
142
142
|
path: string,
|
|
143
143
|
entity: Entity<M> | null
|
|
@@ -147,7 +147,7 @@ export function canEditEntity<M extends Record<string, unknown>, USER extends Us
|
|
|
147
147
|
|
|
148
148
|
export function canCreateEntity<M extends Record<string, unknown>, USER extends User>
|
|
149
149
|
(
|
|
150
|
-
collection:
|
|
150
|
+
collection: CollectionConfig<M>,
|
|
151
151
|
authContext: AuthContext<USER>,
|
|
152
152
|
path: string,
|
|
153
153
|
entity: Entity<M> | null
|
|
@@ -157,7 +157,7 @@ export function canCreateEntity<M extends Record<string, unknown>, USER extends
|
|
|
157
157
|
|
|
158
158
|
export function canDeleteEntity<M extends Record<string, unknown>, USER extends User>
|
|
159
159
|
(
|
|
160
|
-
collection:
|
|
160
|
+
collection: CollectionConfig<M>,
|
|
161
161
|
authContext: AuthContext<USER>,
|
|
162
162
|
path: string,
|
|
163
163
|
entity: Entity<M> | null
|
|
@@ -55,6 +55,9 @@ export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext):
|
|
|
55
55
|
}
|
|
56
56
|
case "authenticated":
|
|
57
57
|
return ctx.uid != null;
|
|
58
|
+
case "existsIn":
|
|
59
|
+
// A membership subquery cannot be run client-side — server-authoritative.
|
|
60
|
+
return "unknown";
|
|
58
61
|
case "raw":
|
|
59
62
|
// Arbitrary SQL cannot be evaluated client-side — never guess.
|
|
60
63
|
return "unknown";
|
|
@@ -96,6 +99,9 @@ function resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): Resolve
|
|
|
96
99
|
// Can't resolve a row column without the row.
|
|
97
100
|
if (!ctx.entity) return { known: false };
|
|
98
101
|
return { known: true, value: ctx.entity.values[operand.name] };
|
|
102
|
+
case "outerField":
|
|
103
|
+
// Only meaningful inside an `existsIn` subquery (server-authoritative).
|
|
104
|
+
return { known: false };
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
|
|
@@ -1,5 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
|
|
2
2
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
|
+
import { getTableName } from "../relations";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for {@link policyToPostgres}.
|
|
7
|
+
*/
|
|
8
|
+
export interface PolicyCompileOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a collection by slug. Required to compile
|
|
11
|
+
* {@link ExistsInPolicyExpression} (`policy.existsIn`) — the compiler needs
|
|
12
|
+
* the joined collection to derive its table name / schema. When omitted, the
|
|
13
|
+
* join table falls back to a snake_cased slug.
|
|
14
|
+
*/
|
|
15
|
+
resolveCollection?: (slug: string) => CollectionConfig | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The lexical scope threaded through compilation. It changes when we descend
|
|
20
|
+
* into an `existsIn` subquery: inside it, `field` refers to the joined table
|
|
21
|
+
* (aliased) while `outerField` refers to the outer RLS row (table-qualified).
|
|
22
|
+
*/
|
|
23
|
+
interface CompileScope {
|
|
24
|
+
/** Collection whose columns a bare `field` operand resolves against. */
|
|
25
|
+
fieldCollection?: CollectionConfig;
|
|
26
|
+
/** SQL prefix for `field` operands (`""` at top level, `"alias".` in a subquery). */
|
|
27
|
+
fieldPrefix: string;
|
|
28
|
+
/** The outer RLS collection, for `outerField` operands. */
|
|
29
|
+
outerCollection?: CollectionConfig;
|
|
30
|
+
/** SQL prefix for `outerField` operands (`""` at top level, `"schema"."table".` in a subquery). */
|
|
31
|
+
outerPrefix: string;
|
|
32
|
+
resolveCollection?: (slug: string) => CollectionConfig | undefined;
|
|
33
|
+
/** Monotonic counter for generating unique subquery aliases. */
|
|
34
|
+
alias: { n: number };
|
|
35
|
+
}
|
|
3
36
|
|
|
4
37
|
/**
|
|
5
38
|
* Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
|
|
@@ -9,7 +42,18 @@ import { toSnakeCase } from "@rebasepro/utils";
|
|
|
9
42
|
* {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
|
|
10
43
|
* and the admin UI derive from the exact same expression.
|
|
11
44
|
*/
|
|
12
|
-
export function policyToPostgres(expr: PolicyExpression, collection?:
|
|
45
|
+
export function policyToPostgres(expr: PolicyExpression, collection?: CollectionConfig, options?: PolicyCompileOptions): string {
|
|
46
|
+
return compile(expr, {
|
|
47
|
+
fieldCollection: collection,
|
|
48
|
+
fieldPrefix: "",
|
|
49
|
+
outerCollection: collection,
|
|
50
|
+
outerPrefix: "",
|
|
51
|
+
resolveCollection: options?.resolveCollection,
|
|
52
|
+
alias: { n: 0 }
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function compile(expr: PolicyExpression, scope: CompileScope): string {
|
|
13
57
|
switch (expr.kind) {
|
|
14
58
|
case "true":
|
|
15
59
|
return "true";
|
|
@@ -18,23 +62,25 @@ export function policyToPostgres(expr: PolicyExpression, collection?: EntityColl
|
|
|
18
62
|
case "and":
|
|
19
63
|
return expr.operands.length === 0
|
|
20
64
|
? "true"
|
|
21
|
-
: expr.operands.map(o => `(${
|
|
65
|
+
: expr.operands.map(o => `(${compile(o, scope)})`).join(" AND ");
|
|
22
66
|
case "or":
|
|
23
67
|
return expr.operands.length === 0
|
|
24
68
|
? "false"
|
|
25
|
-
: expr.operands.map(o => `(${
|
|
69
|
+
: expr.operands.map(o => `(${compile(o, scope)})`).join(" OR ");
|
|
26
70
|
case "not":
|
|
27
71
|
// Render the common `auth.uid() IS NULL` (unauthenticated) form directly.
|
|
28
72
|
if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
|
|
29
|
-
return `NOT (${
|
|
73
|
+
return `NOT (${compile(expr.operand, scope)})`;
|
|
30
74
|
case "compare":
|
|
31
|
-
return `${operandToSql(expr.left,
|
|
75
|
+
return `${operandToSql(expr.left, scope)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, scope)}`;
|
|
32
76
|
case "rolesOverlap":
|
|
33
77
|
return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
|
|
34
78
|
case "rolesContain":
|
|
35
79
|
return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
|
|
36
80
|
case "authenticated":
|
|
37
81
|
return "auth.uid() IS NOT NULL";
|
|
82
|
+
case "existsIn":
|
|
83
|
+
return compileExistsIn(expr, scope);
|
|
38
84
|
case "raw":
|
|
39
85
|
// Full-power escape hatch: `{column}` references resolve to the bare
|
|
40
86
|
// column name (matching the previous raw-SQL behavior).
|
|
@@ -42,6 +88,34 @@ export function policyToPostgres(expr: PolicyExpression, collection?: EntityColl
|
|
|
42
88
|
}
|
|
43
89
|
}
|
|
44
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.
|
|
93
|
+
* Inside the subquery, `field` operands bind to the aliased join table and
|
|
94
|
+
* `outerField` operands bind to the (table-qualified) outer RLS row.
|
|
95
|
+
*/
|
|
96
|
+
function compileExistsIn(expr: ExistsInPolicyExpression, scope: CompileScope): string {
|
|
97
|
+
const join = scope.resolveCollection?.(expr.collection);
|
|
98
|
+
const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);
|
|
99
|
+
const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
|
|
100
|
+
const alias = `_ex${scope.alias.n++}`;
|
|
101
|
+
|
|
102
|
+
// `outerField` inside the subquery must be qualified with the outer table,
|
|
103
|
+
// otherwise a bare column name would bind to the joined table instead.
|
|
104
|
+
const outerTable = scope.outerCollection ? getTableName(scope.outerCollection) : undefined;
|
|
105
|
+
const outerSchema = schemaOf(scope.outerCollection) ?? "public";
|
|
106
|
+
const outerPrefix = outerTable ? `"${outerSchema}"."${outerTable}".` : "";
|
|
107
|
+
|
|
108
|
+
const innerScope: CompileScope = {
|
|
109
|
+
fieldCollection: join,
|
|
110
|
+
fieldPrefix: `"${alias}".`,
|
|
111
|
+
outerCollection: scope.outerCollection,
|
|
112
|
+
outerPrefix,
|
|
113
|
+
resolveCollection: scope.resolveCollection,
|
|
114
|
+
alias: scope.alias
|
|
115
|
+
};
|
|
116
|
+
return `EXISTS (SELECT 1 FROM "${joinSchema}"."${joinTable}" "${alias}" WHERE ${compile(expr.where, innerScope)})`;
|
|
117
|
+
}
|
|
118
|
+
|
|
45
119
|
const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
|
|
46
120
|
eq: "=",
|
|
47
121
|
neq: "!=",
|
|
@@ -51,10 +125,12 @@ const COMPARE_SQL: Record<PolicyCompareOperator, string> = {
|
|
|
51
125
|
gte: ">="
|
|
52
126
|
};
|
|
53
127
|
|
|
54
|
-
function operandToSql(operand: PolicyOperand,
|
|
128
|
+
function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
|
|
55
129
|
switch (operand.kind) {
|
|
56
130
|
case "field":
|
|
57
|
-
return resolveColumnName(operand.name,
|
|
131
|
+
return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
|
|
132
|
+
case "outerField":
|
|
133
|
+
return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
|
|
58
134
|
case "literal":
|
|
59
135
|
return quoteLiteral(operand.value);
|
|
60
136
|
case "authUid":
|
|
@@ -64,7 +140,11 @@ function operandToSql(operand: PolicyOperand, collection?: EntityCollection): st
|
|
|
64
140
|
}
|
|
65
141
|
}
|
|
66
142
|
|
|
67
|
-
function
|
|
143
|
+
function schemaOf(collection?: CollectionConfig): string | undefined {
|
|
144
|
+
return (collection as { schema?: string } | undefined)?.schema || undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveColumnName(propName: string, collection?: CollectionConfig): string {
|
|
68
148
|
const prop = collection?.properties?.[propName] as Property | undefined;
|
|
69
149
|
if (prop && "columnName" in prop && typeof (prop as { columnName?: unknown }).columnName === "string") {
|
|
70
150
|
return (prop as { columnName: string }).columnName;
|
|
@@ -80,6 +160,6 @@ function quoteLiteral(value: string | number | boolean | null): string {
|
|
|
80
160
|
}
|
|
81
161
|
|
|
82
162
|
/** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
|
|
83
|
-
function rolesArraySql(roles: string[]): string {
|
|
163
|
+
function rolesArraySql(roles: readonly string[]): string {
|
|
84
164
|
return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(",")}]`;
|
|
85
165
|
}
|
package/src/util/references.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
2
|
|
|
3
|
-
export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection:
|
|
3
|
+
export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection: CollectionConfig<M>): string | undefined {
|
|
4
4
|
|
|
5
5
|
// find first storage property of type image
|
|
6
6
|
for (const key in collection.properties) {
|
package/src/util/relations.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CollectionConfig, getDataSourceCapabilities, Property, Relation, RelationProperty } from "@rebasepro/types";
|
|
2
2
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
3
|
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
4
4
|
|
|
5
5
|
export function sanitizeRelation(
|
|
6
6
|
relation: Partial<Relation>,
|
|
7
|
-
sourceCollection:
|
|
8
|
-
resolveCollection?: (slugOrTable: string) =>
|
|
7
|
+
sourceCollection: CollectionConfig,
|
|
8
|
+
resolveCollection?: (slugOrTable: string) => CollectionConfig | undefined
|
|
9
9
|
): Relation {
|
|
10
10
|
if (!relation.target) {
|
|
11
11
|
throw new Error("Relation is missing a `target` collection.");
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const rawTarget = relation.target;
|
|
15
|
-
let targetCollection:
|
|
15
|
+
let targetCollection: CollectionConfig | undefined;
|
|
16
16
|
|
|
17
17
|
if (typeof rawTarget === "string") {
|
|
18
18
|
if (resolveCollection) {
|
|
@@ -20,7 +20,7 @@ export function sanitizeRelation(
|
|
|
20
20
|
}
|
|
21
21
|
if (!targetCollection) {
|
|
22
22
|
targetCollection = { slug: rawTarget,
|
|
23
|
-
name: rawTarget } as
|
|
23
|
+
name: rawTarget } as CollectionConfig;
|
|
24
24
|
}
|
|
25
25
|
} else if (typeof rawTarget === "function") {
|
|
26
26
|
const evaluated = rawTarget();
|
|
@@ -30,13 +30,13 @@ name: rawTarget } as EntityCollection;
|
|
|
30
30
|
}
|
|
31
31
|
if (!targetCollection) {
|
|
32
32
|
targetCollection = { slug: evaluated,
|
|
33
|
-
name: evaluated } as
|
|
33
|
+
name: evaluated } as CollectionConfig;
|
|
34
34
|
}
|
|
35
35
|
} else {
|
|
36
36
|
targetCollection = evaluated;
|
|
37
37
|
}
|
|
38
38
|
} else if (rawTarget && typeof rawTarget === "object") {
|
|
39
|
-
targetCollection = rawTarget as
|
|
39
|
+
targetCollection = rawTarget as CollectionConfig;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
if (!targetCollection) {
|
|
@@ -198,10 +198,10 @@ name: evaluated } as EntityCollection;
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/** WeakMap cache — same collection instance always yields the same relation map. */
|
|
201
|
-
const _resolvedRelationsCache = new WeakMap<
|
|
201
|
+
const _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, Relation>>();
|
|
202
202
|
|
|
203
203
|
export function resolveCollectionRelations(
|
|
204
|
-
collection:
|
|
204
|
+
collection: CollectionConfig
|
|
205
205
|
): Record<string, Relation> {
|
|
206
206
|
const cached = _resolvedRelationsCache.get(collection);
|
|
207
207
|
if (cached) return cached;
|
|
@@ -251,7 +251,7 @@ export function resolveCollectionRelations(
|
|
|
251
251
|
|
|
252
252
|
// We previously skipped if the underlying relation was already registered under
|
|
253
253
|
// its canonical relationName in section 1. But we need to keep the property mapping
|
|
254
|
-
// for
|
|
254
|
+
// for FetchService to hydrate the relation back to the correct property key.
|
|
255
255
|
// Deduplication for Drizzle schema generation is handled in generate-drizzle-schema-logic.ts.
|
|
256
256
|
|
|
257
257
|
if (!relation.relationName) {
|
|
@@ -275,7 +275,7 @@ export function resolvePropertyRelation({
|
|
|
275
275
|
}: {
|
|
276
276
|
propertyKey: string;
|
|
277
277
|
property: Property;
|
|
278
|
-
sourceCollection:
|
|
278
|
+
sourceCollection: CollectionConfig;
|
|
279
279
|
}): Relation | undefined {
|
|
280
280
|
if (property.type !== "relation") return undefined;
|
|
281
281
|
|
|
@@ -304,7 +304,7 @@ export function resolvePropertyRelation({
|
|
|
304
304
|
return undefined;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
export function getTableName(collection:
|
|
307
|
+
export function getTableName(collection: CollectionConfig): string {
|
|
308
308
|
if (getDataSourceCapabilities(collection.engine).supportsRelations) {
|
|
309
309
|
return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
|
310
310
|
}
|