forked from votrongdao/FlowX
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathQueryCapabilities.cs
More file actions
352 lines (298 loc) · 13.1 KB
/
Copy pathQueryCapabilities.cs
File metadata and controls
352 lines (298 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
using FlowX;
namespace Crm;
/// <summary>
/// Saves a named query over a custom object.
/// </summary>
/// <remarks>
/// <strong><c>crm.admin</c>, because a saved view is configuration.</strong> It is the thing
/// somebody presses a button on repeatedly, and a representative who could save one could put a
/// five-hundred-row scan behind a button their whole team uses.
/// </remarks>
[Capability("crm.custom.define_list_view", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.admin",
Idempotent = true)]
public sealed class DefineCrmListView : ICapability<DefineListView, ListViewDefined>
{
private readonly CustomSchemaStore _schema;
private readonly QueryStore _queries;
/// <summary>Creates the capability.</summary>
/// <param name="schema">Checks the object and the fields the view names.</param>
/// <param name="queries">Writes the view.</param>
/// <exception cref="ArgumentNullException">Any argument is null.</exception>
public DefineCrmListView(CustomSchemaStore schema, QueryStore queries)
{
ArgumentNullException.ThrowIfNull(schema);
ArgumentNullException.ThrowIfNull(queries);
_schema = schema;
_queries = queries;
}
/// <inheritdoc />
public async ValueTask<Result<ListViewDefined>> ExecuteAsync(
DefineListView input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (!CustomValues.IsUsableName(input.Name))
{
return Result.Fail<ListViewDefined>(CustomSchemaErrors.NameIsNotUsable(input.Name));
}
if (input.Limit is < 1 or > QueryLimits.Max)
{
return Result.Fail<ListViewDefined>(QueryErrors.LimitIsOutOfRange(input.Limit));
}
if (!await _schema.HasObjectAsync(ctx.TenantId, input.Target, ct).ConfigureAwait(false))
{
return Result.Fail<ListViewDefined>(CustomSchemaErrors.ObjectNotFound(input.Target));
}
var declared = await _schema.FieldsForAsync(ctx.TenantId, input.Target, ct).ConfigureAwait(false);
if (input.Filter is { Criteria.Count: > QueryLimits.MaxCriteria } tooMany)
{
return Result.Fail<ListViewDefined>(
QueryErrors.TooManyCriteria(tooMany.Criteria.Count));
}
// Every criterion's field and the ordering's, checked when the view is saved. A saved
// view naming a field nobody declared would return everything or nothing for ever, and
// whoever pressed the button would believe the answer.
var named = (input.Filter?.Criteria ?? [])
.Select(static criterion => criterion.Field)
.Append(input.Order?.Field);
foreach (var field in named)
{
if (field is { Length: > 0 } && !declared.ContainsKey(field))
{
return Result.Fail<ListViewDefined>(
FieldPolicyErrors.RuleNamesNoField(field, declared.Keys));
}
}
if (Layout(input.Layout, declared) is { } badLayout)
{
return Result.Fail<ListViewDefined>(badLayout);
}
var id = await _queries
.SaveViewAsync(ctx.TenantId, ctx.NewId(), input, ctx.UtcNow, ct)
.ConfigureAwait(false);
return id is null
? Result.Fail<ListViewDefined>(CustomSchemaErrors.NameIsTaken(input.Name))
: Result.Ok(new ListViewDefined(id.Value, input.Name));
}
/// <summary>What a shape has to have, and what it must not.</summary>
/// <remarks>
/// <para>
/// <strong>A setting for the wrong shape is refused, not ignored.</strong> A stored setting
/// nothing reads is a setting somebody changes, saves and watches do nothing — and the next
/// person spends an afternoon finding out why.
/// </para>
/// <para>
/// <strong>Every field a layout names is checked against the declarations</strong>, for the
/// same reason the filter's fields are: a board grouped by a field nobody declared is one
/// empty lane, every morning, and whoever built it would believe the answer.
/// </para>
/// </remarks>
private static Error? Layout(
ViewLayout? layout,
IReadOnlyDictionary<string, CustomFieldRow> declared)
{
if (layout is null)
{
return null;
}
var kanban = layout.Kind == ViewKind.Kanban;
var card = layout.Kind == ViewKind.Card;
if (kanban != (layout.GroupBy is not null))
{
return kanban
? QueryErrors.KindNeedsItsSetting(layout.Kind, nameof(ViewLayout.GroupBy))
: QueryErrors.SettingIsNotOfKind(layout.Kind, nameof(ViewLayout.GroupBy));
}
if (card != (layout.TitleField is not null))
{
return card
? QueryErrors.KindNeedsItsSetting(layout.Kind, nameof(ViewLayout.TitleField))
: QueryErrors.SettingIsNotOfKind(layout.Kind, nameof(ViewLayout.TitleField));
}
if (!card && layout.SubtitleField is not null)
{
return QueryErrors.SettingIsNotOfKind(layout.Kind, nameof(ViewLayout.SubtitleField));
}
if (!kanban && (layout.Lanes is { Count: > 0 } || layout.WipLimit is not null))
{
return QueryErrors.SettingIsNotOfKind(
layout.Kind,
layout.WipLimit is not null ? nameof(ViewLayout.WipLimit) : nameof(ViewLayout.Lanes));
}
if (layout.Kind != ViewKind.List && layout.Columns is { Count: > 0 })
{
return QueryErrors.SettingIsNotOfKind(layout.Kind, nameof(ViewLayout.Columns));
}
if (layout.WipLimit is { } limit and < 1)
{
return QueryErrors.WipLimitIsNotUsable(limit);
}
foreach (var field in (layout.Columns ?? [])
.Append(layout.GroupBy)
.Append(layout.TitleField)
.Append(layout.SubtitleField))
{
if (field is { Length: > 0 } && !declared.ContainsKey(field))
{
return FieldPolicyErrors.RuleNamesNoField(field, declared.Keys);
}
}
return null;
}
}
/// <summary>
/// Reads records of a custom object, with anything the caller may not see redacted.
/// </summary>
/// <remarks>
/// <para>
/// <strong>This is the only projection of custom values, and that is what makes read-side field
/// security tractable.</strong> Migration <c>0007</c> declined to do the read half on the
/// grounds that a rule not applied to every projection is a rule that leaks; the answer is that
/// there is one, and it masks. <c>CustomFieldPolicy.Mask</c> is a function rather than three
/// lines here so that a second projection has something to call.
/// </para>
/// <para>
/// <strong><c>crm.read</c> to reach it at all, and a per-field grant on top.</strong> The two are
/// different questions: the first is whether you may query this tenant's records, the second is
/// whether this particular column is yours to see.
/// </para>
/// </remarks>
[Capability("crm.custom.query_records", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.read",
Idempotent = true)]
public sealed class QueryCustomRecords : ICapability<ReadObjectRecords, RecordPage>
{
private readonly CustomSchemaStore _schema;
private readonly QueryStore _queries;
/// <summary>Creates the capability.</summary>
/// <param name="schema">Reads the declarations, for what may be masked.</param>
/// <param name="queries">Reads the view and the rows.</param>
/// <exception cref="ArgumentNullException">Any argument is null.</exception>
public QueryCustomRecords(CustomSchemaStore schema, QueryStore queries)
{
ArgumentNullException.ThrowIfNull(schema);
ArgumentNullException.ThrowIfNull(queries);
_schema = schema;
_queries = queries;
}
/// <inheritdoc />
public async ValueTask<Result<RecordPage>> ExecuteAsync(
ReadObjectRecords input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
var query = input.Query;
if (query.Target is null == query.View is null)
{
return Result.Fail<RecordPage>(QueryErrors.AskForOneOrTheOther());
}
var resolved = query.View is { Length: > 0 } name
? await _queries.ReadViewAsync(ctx.TenantId, name, ct).ConfigureAwait(false)
: (query.Target!.Value, query.Filter, (RecordOrder?)null, query.Limit);
if (resolved is not { } plan)
{
return Result.Fail<RecordPage>(QueryErrors.ViewNotFound(query.View!));
}
if (plan.Limit is < 1 or > QueryLimits.Max)
{
return Result.Fail<RecordPage>(QueryErrors.LimitIsOutOfRange(plan.Limit));
}
if (plan.Filter is { Criteria.Count: > QueryLimits.MaxCriteria } tooMany)
{
return Result.Fail<RecordPage>(QueryErrors.TooManyCriteria(tooMany.Criteria.Count));
}
(DateTimeOffset CreatedAt, Guid RecordId)? after = null;
if (query.After is { Length: > 0 } cursor)
{
if (plan.Order is not null)
{
return Result.Fail<RecordPage>(QueryErrors.CursorNeedsInsertionOrder());
}
after = RecordCursor.Read(cursor);
if (after is null)
{
return Result.Fail<RecordPage>(QueryErrors.CursorIsNotUsable(cursor));
}
}
if (!await _schema.HasObjectAsync(ctx.TenantId, plan.Target, ct).ConfigureAwait(false))
{
return Result.Fail<RecordPage>(CustomSchemaErrors.ObjectNotFound(plan.Target));
}
var declared = await _schema.FieldsForAsync(ctx.TenantId, plan.Target, ct).ConfigureAwait(false);
var rows = await _queries
.RecordsAsync(ctx.TenantId, plan.Target, plan.Filter, plan.Order, plan.Limit, after, ct)
.ConfigureAwait(false);
var redacted = new SortedSet<string>(StringComparer.Ordinal);
var records = rows
.Select(row => new RecordView(
row.Id,
CustomFieldPolicy.Mask(
declared, CustomValues.FromJson(row.Values), input.Scopes, redacted)))
.ToList();
// A cursor only when a full page came back. Deciding it that way means a caller never
// makes a request that returns nothing, at the cost of one extra request when the last
// page happens to be exactly full — which is the cheaper of the two mistakes.
var next = rows.Count == plan.Limit && plan.Order is null
? RecordCursor.For(rows[^1].CreatedAt, rows[^1].Id)
: null;
return Result.Ok(new RecordPage(records, [.. redacted], next));
}
}
/// <summary>
/// Searches every entity this tenant has for a phrase.
/// </summary>
/// <remarks>
/// <para>
/// <strong>One statement over five tables, and the tenant is in none of its predicates.</strong>
/// Row-level security scopes all five at once, which is the whole argument for the tenant being
/// a connection setting rather than a <c>WHERE</c> clause somebody has to remember to write in
/// a sixth place.
/// </para>
/// <para>
/// <strong>A hit is an identity, not a row.</strong> It carries the kind, the id and a title
/// taken from the entity's own columns — never a custom field, because those are read-secured per
/// field and a search returning them would be a second unmasked projection of them. A caller
/// follows a hit to the query surface, which decides what they may see.
/// </para>
/// </remarks>
[Capability("crm.search", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.read",
Idempotent = true)]
public sealed class SearchCrm : ICapability<SearchEverything, SearchResults>
{
private readonly QueryStore _queries;
/// <summary>Creates the capability.</summary>
/// <param name="queries">Runs the search.</param>
/// <exception cref="ArgumentNullException"><paramref name="queries"/> is null.</exception>
public SearchCrm(QueryStore queries)
{
ArgumentNullException.ThrowIfNull(queries);
_queries = queries;
}
/// <inheritdoc />
public async ValueTask<Result<SearchResults>> ExecuteAsync(
SearchEverything input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (string.IsNullOrWhiteSpace(input.Phrase))
{
return Result.Fail<SearchResults>(QueryErrors.PhraseIsEmpty());
}
if (input.Limit is < 1 or > QueryLimits.Max)
{
return Result.Fail<SearchResults>(QueryErrors.LimitIsOutOfRange(input.Limit));
}
var hits = await _queries
.SearchAsync(ctx.TenantId, input.Phrase, input.Limit, ct)
.ConfigureAwait(false);
return Result.Ok(new SearchResults(hits));
}
}