Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9265e7903 | |||
| a05f58f7fe | |||
| 93c8cae1e6 | |||
| 5a611bbc9b | |||
| aadc35581d | |||
| e61b0a36ac |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
||||
"sourceCommit": "53b9a56e8cb120c34d6b5c85b52b18080dd1d9a3",
|
||||
"sourceCommit": "4c88728f144add3457b4273d4b9a04ee53c53be3",
|
||||
"sourcePath": "quixos-protocol",
|
||||
"exportName": "quixos-protocol",
|
||||
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
|
||||
|
||||
@@ -408,6 +408,9 @@ identifier
|
||||
| POLL
|
||||
| RPC
|
||||
| QUERYABLE
|
||||
| RESULT_BYTES
|
||||
| RPC_CALLS
|
||||
| DEADLINE_MS
|
||||
;
|
||||
|
||||
stringLiteral
|
||||
@@ -415,6 +418,9 @@ stringLiteral
|
||||
;
|
||||
|
||||
WORKSPACE: 'workspace';
|
||||
RESULT_BYTES: 'result-bytes';
|
||||
RPC_CALLS: 'rpc-calls';
|
||||
DEADLINE_MS: 'deadline-ms';
|
||||
QUERY: 'query';
|
||||
SPECIALIZE: 'specialize';
|
||||
ROOT: 'root';
|
||||
|
||||
@@ -242,6 +242,8 @@ message QueryPendingField {
|
||||
optional uint32 residual_window = 7;
|
||||
uint32 residual_row = 8;
|
||||
string residual_field = 9;
|
||||
optional uint32 relational_capture = 10;
|
||||
uint32 captured_object = 11;
|
||||
}
|
||||
message QueryStats {
|
||||
uint32 sql_count = 1;
|
||||
@@ -251,6 +253,10 @@ message QueryStats {
|
||||
uint32 result_bytes = 5;
|
||||
double preparation_ms = 6;
|
||||
double total_ms = 7;
|
||||
uint32 relational_stages = 8;
|
||||
uint32 captured_candidates = 9;
|
||||
double relational_ms = 10;
|
||||
double residual_ms = 11;
|
||||
}
|
||||
message QueryResponse {
|
||||
Value value = 1;
|
||||
@@ -264,6 +270,35 @@ message QueryResponse {
|
||||
repeated QueryResidualWindow residual_windows = 8;
|
||||
// Native reads share one database snapshot; package enrichment does not.
|
||||
string consistency = 9; // native-snapshot | mixed
|
||||
repeated QueryRelationalCapture relational_captures = 10;
|
||||
}
|
||||
|
||||
// Private coordinator input, removed before publishing a result. Memberships
|
||||
// and native facts share one snapshot; package reads are sampled afterwards.
|
||||
message QueryCapturedMember {
|
||||
string object_id = 1;
|
||||
string entry_id = 2;
|
||||
Value map_key = 3;
|
||||
}
|
||||
message QueryCapturedMembers {
|
||||
repeated QueryCapturedMember entries = 1;
|
||||
}
|
||||
message QueryCapturedObject {
|
||||
string object_id = 1;
|
||||
map<string, Value> fields = 2;
|
||||
map<string, string> field_types = 3;
|
||||
map<string, QueryCapturedMembers> relationships = 4;
|
||||
}
|
||||
message QueryRelationalCapture {
|
||||
repeated QueryPathPart path = 1;
|
||||
QueryRelationalPlan plan = 2;
|
||||
string root_object_id = 3;
|
||||
repeated QueryCapturedObject objects = 4;
|
||||
string variables_json = 5;
|
||||
uint32 row_limit = 6;
|
||||
uint32 candidate_limit = 7;
|
||||
optional uint32 residual_window = 8;
|
||||
repeated string result_path = 9;
|
||||
}
|
||||
|
||||
message QueryResidualRow {
|
||||
@@ -283,6 +318,8 @@ message QueryResidualWindow {
|
||||
bool bounded_all = 6;
|
||||
repeated QuerySelection selection = 7;
|
||||
map<string, string> field_types = 8;
|
||||
bool relational = 9;
|
||||
repeated string matched_entries = 10;
|
||||
}
|
||||
|
||||
message QueryFieldFailure {
|
||||
|
||||
@@ -102,6 +102,171 @@ message QuerySelection {
|
||||
repeated QueryCondition conditions = 6;
|
||||
map<string, QueryArgument> arguments = 7;
|
||||
repeated QuerySelection selection = 8;
|
||||
QueryRelationalPlan relational = 9;
|
||||
QueryPredicate predicate = 10;
|
||||
}
|
||||
// Resolved IDs, not GraphQL names, determine execution. Response selections
|
||||
// remain separate so aliases and fragments cannot change relational semantics.
|
||||
message QueryPathStep {
|
||||
oneof step {
|
||||
bool source = 1;
|
||||
bool target = 2;
|
||||
QueryRelationPath relation = 3;
|
||||
}
|
||||
}
|
||||
message QueryRelationPath {
|
||||
string interface_revision_id = 1;
|
||||
string member_id = 2;
|
||||
string target_interface_revision_id = 3;
|
||||
bool optional = 4;
|
||||
}
|
||||
message QueryFieldOperand {
|
||||
string interface_revision_id = 1;
|
||||
string member_id = 2;
|
||||
}
|
||||
message QueryExpression {
|
||||
repeated QueryPathStep path = 1;
|
||||
oneof leaf {
|
||||
QueryFieldOperand field = 2;
|
||||
string ref = 3;
|
||||
bool entry = 4;
|
||||
bool map_key = 5;
|
||||
}
|
||||
string value_type_json = 6;
|
||||
}
|
||||
enum QueryAggregateOperator {
|
||||
QUERY_AGGREGATE_OPERATOR_UNSPECIFIED = 0;
|
||||
QUERY_AGGREGATE_OPERATOR_COUNT = 1;
|
||||
QUERY_AGGREGATE_OPERATOR_COUNT_PRESENT = 2;
|
||||
QUERY_AGGREGATE_OPERATOR_SUM = 3;
|
||||
QUERY_AGGREGATE_OPERATOR_AVG = 4;
|
||||
QUERY_AGGREGATE_OPERATOR_MIN = 5;
|
||||
QUERY_AGGREGATE_OPERATOR_MAX = 6;
|
||||
}
|
||||
message QueryReduction {
|
||||
QueryAggregateOperator operator = 1;
|
||||
QueryExpression operand = 2;
|
||||
string value_type_json = 3;
|
||||
}
|
||||
message QueryOperand {
|
||||
oneof operand {
|
||||
QueryExpression expression = 1;
|
||||
QueryReduction reduction = 2;
|
||||
}
|
||||
}
|
||||
enum QueryComparisonOperator {
|
||||
QUERY_COMPARISON_OPERATOR_UNSPECIFIED = 0;
|
||||
QUERY_COMPARISON_OPERATOR_EQ = 1;
|
||||
QUERY_COMPARISON_OPERATOR_IN = 2;
|
||||
QUERY_COMPARISON_OPERATOR_IS_NULL = 3;
|
||||
QUERY_COMPARISON_OPERATOR_LT = 4;
|
||||
QUERY_COMPARISON_OPERATOR_LTE = 5;
|
||||
QUERY_COMPARISON_OPERATOR_GT = 6;
|
||||
QUERY_COMPARISON_OPERATOR_GTE = 7;
|
||||
}
|
||||
message QueryComparison {
|
||||
QueryOperand operand = 1;
|
||||
QueryComparisonOperator operator = 2;
|
||||
QueryArgument value = 3;
|
||||
}
|
||||
message QueryPredicateList {
|
||||
repeated QueryPredicate children = 1;
|
||||
}
|
||||
enum QueryRelationPredicateOperator {
|
||||
QUERY_RELATION_PREDICATE_OPERATOR_UNSPECIFIED = 0;
|
||||
QUERY_RELATION_PREDICATE_OPERATOR_SOME = 1;
|
||||
QUERY_RELATION_PREDICATE_OPERATOR_NONE = 2;
|
||||
QUERY_RELATION_PREDICATE_OPERATOR_IS = 3;
|
||||
QUERY_RELATION_PREDICATE_OPERATOR_IS_NULL = 4;
|
||||
}
|
||||
message QueryRelationPredicate {
|
||||
repeated QueryPathStep path = 1;
|
||||
QueryRelationPath relation = 2;
|
||||
QueryRelationPredicateOperator operator = 3;
|
||||
QueryPredicate predicate = 4;
|
||||
QueryArgument value = 5;
|
||||
}
|
||||
message QueryReductionPredicate {
|
||||
repeated QueryPathStep path = 1;
|
||||
QueryRelationPath relation = 2;
|
||||
QueryPredicate where = 3;
|
||||
QueryPredicate having = 4;
|
||||
}
|
||||
message QueryPredicate {
|
||||
oneof predicate {
|
||||
QueryPredicateList and = 1;
|
||||
QueryPredicateList or = 2;
|
||||
QueryPredicate not = 3;
|
||||
QueryComparison compare = 4;
|
||||
QueryRelationPredicate relation = 5;
|
||||
QueryReductionPredicate reduce = 6;
|
||||
}
|
||||
}
|
||||
message QueryRow {
|
||||
oneof row {
|
||||
QueryObjectRow object = 1;
|
||||
QueryPairRow pair = 2;
|
||||
}
|
||||
}
|
||||
message QueryObjectRow {
|
||||
string interface_revision_id = 1;
|
||||
bool membership = 2;
|
||||
string key_type = 3;
|
||||
}
|
||||
message QueryPairRow {
|
||||
QueryRow source = 1;
|
||||
QueryRow target = 2;
|
||||
}
|
||||
message QueryKey {
|
||||
repeated string path = 1;
|
||||
QueryExpression expression = 2;
|
||||
}
|
||||
message QueryDistinct {
|
||||
repeated QueryKey keys = 1;
|
||||
}
|
||||
message QueryExpansion {
|
||||
repeated QueryPathStep path = 1;
|
||||
QueryRelationPath relation = 2;
|
||||
}
|
||||
message QueryRelationalStage {
|
||||
uint32 id = 1;
|
||||
optional uint32 input = 2;
|
||||
QueryRow row = 3;
|
||||
oneof operation {
|
||||
QueryRelationPath source = 4;
|
||||
QueryPredicate filter = 5;
|
||||
QueryExpansion expand = 6;
|
||||
QueryDistinct distinct = 7;
|
||||
}
|
||||
}
|
||||
message QueryReductionProjection {
|
||||
repeated string path = 1;
|
||||
QueryReduction reduction = 2;
|
||||
}
|
||||
message QueryAggregateOrder {
|
||||
QueryOperand operand = 1;
|
||||
bool descending = 2;
|
||||
}
|
||||
message QueryRelationalTerminal {
|
||||
uint32 stage = 1;
|
||||
repeated string path = 2;
|
||||
bool groups = 3;
|
||||
repeated QueryKey keys = 4;
|
||||
repeated QueryReductionProjection reductions = 5;
|
||||
QueryPredicate where = 6;
|
||||
QueryPredicate having = 7;
|
||||
repeated QueryAggregateOrder order = 8;
|
||||
QueryArgument first = 9;
|
||||
QueryArgument all = 10;
|
||||
QueryArgument after = 11;
|
||||
repeated QuerySelection selection = 12;
|
||||
bool residual = 13;
|
||||
// Internal ordinary-relationship residual: yields ordered membership IDs.
|
||||
bool rows = 14;
|
||||
}
|
||||
message QueryRelationalPlan {
|
||||
repeated QueryRelationalStage stages = 1;
|
||||
repeated QueryRelationalTerminal terminals = 2;
|
||||
}
|
||||
message QueryReadBinding {
|
||||
string atom_id = 1;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,253 +1,259 @@
|
||||
WORKSPACE=1
|
||||
QUERY=2
|
||||
SPECIALIZE=3
|
||||
ROOT=4
|
||||
DOCUMENT=5
|
||||
FRAGMENTS=6
|
||||
VIEW=7
|
||||
MAX=8
|
||||
ALLOW=9
|
||||
POLL=10
|
||||
QUERYABLE=11
|
||||
RPC=12
|
||||
QUERY_REASON=13
|
||||
TYPE=14
|
||||
OBJECT=15
|
||||
STORABLE=16
|
||||
IMPLEMENTS=17
|
||||
REF=18
|
||||
FRAGMENT=19
|
||||
IMPORT=20
|
||||
EXTERNAL=21
|
||||
ATOM=22
|
||||
INTERFACE=23
|
||||
INTERFACES=24
|
||||
PACKAGE=25
|
||||
VALUE=26
|
||||
RELATION=27
|
||||
OPERATION=28
|
||||
FUNCTION=29
|
||||
CONSTRUCTOR=30
|
||||
CONSTRUCTS=31
|
||||
INPUT=32
|
||||
CONFORM=33
|
||||
AS=34
|
||||
BIND=35
|
||||
STATIC=36
|
||||
TO=37
|
||||
PRIVATE=38
|
||||
SHARED=39
|
||||
STATE=40
|
||||
EDGE=41
|
||||
PROJECTION=42
|
||||
WITH=43
|
||||
USING=44
|
||||
VIA=45
|
||||
MATERIALIZE=46
|
||||
IF=47
|
||||
ABSENT=48
|
||||
ON=49
|
||||
POLICY=50
|
||||
DEFAULT=51
|
||||
SOURCE=52
|
||||
REPOSITORY=53
|
||||
COMMIT=54
|
||||
REVISION=55
|
||||
SEMANTIC_MAJOR=56
|
||||
ON_DELETE=57
|
||||
RETAIN_OTHER=58
|
||||
KEYED=59
|
||||
PUBLIC_TRAVERSAL=60
|
||||
ID=61
|
||||
DOC=62
|
||||
MODE=63
|
||||
EMITS=64
|
||||
RECEIVER=65
|
||||
REQUIRES=66
|
||||
ANY=67
|
||||
GET=68
|
||||
SET=69
|
||||
WATCH=70
|
||||
START=71
|
||||
STOP=72
|
||||
READ=73
|
||||
WRITE=74
|
||||
RESOLVE=75
|
||||
CONNECT=76
|
||||
DISCONNECT=77
|
||||
CALL=78
|
||||
WATCH_START=79
|
||||
WATCH_STOP=80
|
||||
SUBSCRIBE=81
|
||||
UNSUBSCRIBE=82
|
||||
OPTIMISTIC_REGISTER=83
|
||||
CRDT=84
|
||||
OPTIONAL_ONE=85
|
||||
EXACTLY_ONE=86
|
||||
MANY_UNIQUE=87
|
||||
MANY=88
|
||||
ORDERED=89
|
||||
UNIT=90
|
||||
WATCH_HANDLE=91
|
||||
MESSAGE=92
|
||||
ATOM_REF=93
|
||||
INTERFACE_REF=94
|
||||
OPTIONAL=95
|
||||
LIST=96
|
||||
RECORD=97
|
||||
BOOL=98
|
||||
BYTES=99
|
||||
DOUBLE=100
|
||||
INT32=101
|
||||
INT64=102
|
||||
STRING=103
|
||||
UINT32=104
|
||||
UINT64=105
|
||||
TRUE=106
|
||||
FALSE=107
|
||||
NULL=108
|
||||
ARROW=109
|
||||
COLON=110
|
||||
SEMI=111
|
||||
COMMA=112
|
||||
DOT=113
|
||||
LBRACE=114
|
||||
RBRACE=115
|
||||
LBRACK=116
|
||||
RBRACK=117
|
||||
LPAREN=118
|
||||
RPAREN=119
|
||||
LT=120
|
||||
GT=121
|
||||
AMP=122
|
||||
EQUAL=123
|
||||
INTEGER=124
|
||||
JSON_NUMBER=125
|
||||
IDENTIFIER=126
|
||||
STRING_LITERAL=127
|
||||
LINE_COMMENT=128
|
||||
BLOCK_COMMENT=129
|
||||
WS=130
|
||||
RESULT_BYTES=2
|
||||
RPC_CALLS=3
|
||||
DEADLINE_MS=4
|
||||
QUERY=5
|
||||
SPECIALIZE=6
|
||||
ROOT=7
|
||||
DOCUMENT=8
|
||||
FRAGMENTS=9
|
||||
VIEW=10
|
||||
MAX=11
|
||||
ALLOW=12
|
||||
POLL=13
|
||||
QUERYABLE=14
|
||||
RPC=15
|
||||
QUERY_REASON=16
|
||||
TYPE=17
|
||||
OBJECT=18
|
||||
STORABLE=19
|
||||
IMPLEMENTS=20
|
||||
REF=21
|
||||
FRAGMENT=22
|
||||
IMPORT=23
|
||||
EXTERNAL=24
|
||||
ATOM=25
|
||||
INTERFACE=26
|
||||
INTERFACES=27
|
||||
PACKAGE=28
|
||||
VALUE=29
|
||||
RELATION=30
|
||||
OPERATION=31
|
||||
FUNCTION=32
|
||||
CONSTRUCTOR=33
|
||||
CONSTRUCTS=34
|
||||
INPUT=35
|
||||
CONFORM=36
|
||||
AS=37
|
||||
BIND=38
|
||||
STATIC=39
|
||||
TO=40
|
||||
PRIVATE=41
|
||||
SHARED=42
|
||||
STATE=43
|
||||
EDGE=44
|
||||
PROJECTION=45
|
||||
WITH=46
|
||||
USING=47
|
||||
VIA=48
|
||||
MATERIALIZE=49
|
||||
IF=50
|
||||
ABSENT=51
|
||||
ON=52
|
||||
POLICY=53
|
||||
DEFAULT=54
|
||||
SOURCE=55
|
||||
REPOSITORY=56
|
||||
COMMIT=57
|
||||
REVISION=58
|
||||
SEMANTIC_MAJOR=59
|
||||
ON_DELETE=60
|
||||
RETAIN_OTHER=61
|
||||
KEYED=62
|
||||
PUBLIC_TRAVERSAL=63
|
||||
ID=64
|
||||
DOC=65
|
||||
MODE=66
|
||||
EMITS=67
|
||||
RECEIVER=68
|
||||
REQUIRES=69
|
||||
ANY=70
|
||||
GET=71
|
||||
SET=72
|
||||
WATCH=73
|
||||
START=74
|
||||
STOP=75
|
||||
READ=76
|
||||
WRITE=77
|
||||
RESOLVE=78
|
||||
CONNECT=79
|
||||
DISCONNECT=80
|
||||
CALL=81
|
||||
WATCH_START=82
|
||||
WATCH_STOP=83
|
||||
SUBSCRIBE=84
|
||||
UNSUBSCRIBE=85
|
||||
OPTIMISTIC_REGISTER=86
|
||||
CRDT=87
|
||||
OPTIONAL_ONE=88
|
||||
EXACTLY_ONE=89
|
||||
MANY_UNIQUE=90
|
||||
MANY=91
|
||||
ORDERED=92
|
||||
UNIT=93
|
||||
WATCH_HANDLE=94
|
||||
MESSAGE=95
|
||||
ATOM_REF=96
|
||||
INTERFACE_REF=97
|
||||
OPTIONAL=98
|
||||
LIST=99
|
||||
RECORD=100
|
||||
BOOL=101
|
||||
BYTES=102
|
||||
DOUBLE=103
|
||||
INT32=104
|
||||
INT64=105
|
||||
STRING=106
|
||||
UINT32=107
|
||||
UINT64=108
|
||||
TRUE=109
|
||||
FALSE=110
|
||||
NULL=111
|
||||
ARROW=112
|
||||
COLON=113
|
||||
SEMI=114
|
||||
COMMA=115
|
||||
DOT=116
|
||||
LBRACE=117
|
||||
RBRACE=118
|
||||
LBRACK=119
|
||||
RBRACK=120
|
||||
LPAREN=121
|
||||
RPAREN=122
|
||||
LT=123
|
||||
GT=124
|
||||
AMP=125
|
||||
EQUAL=126
|
||||
INTEGER=127
|
||||
JSON_NUMBER=128
|
||||
IDENTIFIER=129
|
||||
STRING_LITERAL=130
|
||||
LINE_COMMENT=131
|
||||
BLOCK_COMMENT=132
|
||||
WS=133
|
||||
'workspace'=1
|
||||
'query'=2
|
||||
'specialize'=3
|
||||
'root'=4
|
||||
'document'=5
|
||||
'fragments'=6
|
||||
'view'=7
|
||||
'max'=8
|
||||
'allow'=9
|
||||
'poll'=10
|
||||
'queryable'=11
|
||||
'rpc'=12
|
||||
'query-reason'=13
|
||||
'type'=14
|
||||
'object'=15
|
||||
'storable'=16
|
||||
'implements'=17
|
||||
'ref'=18
|
||||
'fragment'=19
|
||||
'import'=20
|
||||
'external'=21
|
||||
'atom'=22
|
||||
'interface'=23
|
||||
'interfaces'=24
|
||||
'package'=25
|
||||
'value'=26
|
||||
'relation'=27
|
||||
'operation'=28
|
||||
'function'=29
|
||||
'constructor'=30
|
||||
'constructs'=31
|
||||
'input'=32
|
||||
'conform'=33
|
||||
'as'=34
|
||||
'bind'=35
|
||||
'static'=36
|
||||
'to'=37
|
||||
'private'=38
|
||||
'shared'=39
|
||||
'state'=40
|
||||
'edge'=41
|
||||
'projection'=42
|
||||
'with'=43
|
||||
'using'=44
|
||||
'via'=45
|
||||
'materialize'=46
|
||||
'if'=47
|
||||
'absent'=48
|
||||
'on'=49
|
||||
'policy'=50
|
||||
'default'=51
|
||||
'source'=52
|
||||
'repository'=53
|
||||
'commit'=54
|
||||
'revision'=55
|
||||
'semantic-major'=56
|
||||
'on-delete'=57
|
||||
'retain-other'=58
|
||||
'keyed'=59
|
||||
'public-traversal'=60
|
||||
'id'=61
|
||||
'doc'=62
|
||||
'mode'=63
|
||||
'emits'=64
|
||||
'receiver'=65
|
||||
'requires'=66
|
||||
'any'=67
|
||||
'get'=68
|
||||
'set'=69
|
||||
'watch'=70
|
||||
'start'=71
|
||||
'stop'=72
|
||||
'read'=73
|
||||
'write'=74
|
||||
'resolve'=75
|
||||
'connect'=76
|
||||
'disconnect'=77
|
||||
'call'=78
|
||||
'watch-start'=79
|
||||
'watch-stop'=80
|
||||
'subscribe'=81
|
||||
'unsubscribe'=82
|
||||
'optimistic-register'=83
|
||||
'crdt'=84
|
||||
'optional-one'=85
|
||||
'exactly-one'=86
|
||||
'many-unique'=87
|
||||
'many'=88
|
||||
'ordered'=89
|
||||
'unit'=90
|
||||
'watch-handle'=91
|
||||
'message'=92
|
||||
'atom-ref'=93
|
||||
'interface-ref'=94
|
||||
'optional'=95
|
||||
'list'=96
|
||||
'record'=97
|
||||
'bool'=98
|
||||
'bytes'=99
|
||||
'double'=100
|
||||
'int32'=101
|
||||
'int64'=102
|
||||
'string'=103
|
||||
'uint32'=104
|
||||
'uint64'=105
|
||||
'true'=106
|
||||
'false'=107
|
||||
'null'=108
|
||||
'->'=109
|
||||
':'=110
|
||||
';'=111
|
||||
','=112
|
||||
'.'=113
|
||||
'{'=114
|
||||
'}'=115
|
||||
'['=116
|
||||
']'=117
|
||||
'('=118
|
||||
')'=119
|
||||
'<'=120
|
||||
'>'=121
|
||||
'&'=122
|
||||
'='=123
|
||||
'result-bytes'=2
|
||||
'rpc-calls'=3
|
||||
'deadline-ms'=4
|
||||
'query'=5
|
||||
'specialize'=6
|
||||
'root'=7
|
||||
'document'=8
|
||||
'fragments'=9
|
||||
'view'=10
|
||||
'max'=11
|
||||
'allow'=12
|
||||
'poll'=13
|
||||
'queryable'=14
|
||||
'rpc'=15
|
||||
'query-reason'=16
|
||||
'type'=17
|
||||
'object'=18
|
||||
'storable'=19
|
||||
'implements'=20
|
||||
'ref'=21
|
||||
'fragment'=22
|
||||
'import'=23
|
||||
'external'=24
|
||||
'atom'=25
|
||||
'interface'=26
|
||||
'interfaces'=27
|
||||
'package'=28
|
||||
'value'=29
|
||||
'relation'=30
|
||||
'operation'=31
|
||||
'function'=32
|
||||
'constructor'=33
|
||||
'constructs'=34
|
||||
'input'=35
|
||||
'conform'=36
|
||||
'as'=37
|
||||
'bind'=38
|
||||
'static'=39
|
||||
'to'=40
|
||||
'private'=41
|
||||
'shared'=42
|
||||
'state'=43
|
||||
'edge'=44
|
||||
'projection'=45
|
||||
'with'=46
|
||||
'using'=47
|
||||
'via'=48
|
||||
'materialize'=49
|
||||
'if'=50
|
||||
'absent'=51
|
||||
'on'=52
|
||||
'policy'=53
|
||||
'default'=54
|
||||
'source'=55
|
||||
'repository'=56
|
||||
'commit'=57
|
||||
'revision'=58
|
||||
'semantic-major'=59
|
||||
'on-delete'=60
|
||||
'retain-other'=61
|
||||
'keyed'=62
|
||||
'public-traversal'=63
|
||||
'id'=64
|
||||
'doc'=65
|
||||
'mode'=66
|
||||
'emits'=67
|
||||
'receiver'=68
|
||||
'requires'=69
|
||||
'any'=70
|
||||
'get'=71
|
||||
'set'=72
|
||||
'watch'=73
|
||||
'start'=74
|
||||
'stop'=75
|
||||
'read'=76
|
||||
'write'=77
|
||||
'resolve'=78
|
||||
'connect'=79
|
||||
'disconnect'=80
|
||||
'call'=81
|
||||
'watch-start'=82
|
||||
'watch-stop'=83
|
||||
'subscribe'=84
|
||||
'unsubscribe'=85
|
||||
'optimistic-register'=86
|
||||
'crdt'=87
|
||||
'optional-one'=88
|
||||
'exactly-one'=89
|
||||
'many-unique'=90
|
||||
'many'=91
|
||||
'ordered'=92
|
||||
'unit'=93
|
||||
'watch-handle'=94
|
||||
'message'=95
|
||||
'atom-ref'=96
|
||||
'interface-ref'=97
|
||||
'optional'=98
|
||||
'list'=99
|
||||
'record'=100
|
||||
'bool'=101
|
||||
'bytes'=102
|
||||
'double'=103
|
||||
'int32'=104
|
||||
'int64'=105
|
||||
'string'=106
|
||||
'uint32'=107
|
||||
'uint64'=108
|
||||
'true'=109
|
||||
'false'=110
|
||||
'null'=111
|
||||
'->'=112
|
||||
':'=113
|
||||
';'=114
|
||||
','=115
|
||||
'.'=116
|
||||
'{'=117
|
||||
'}'=118
|
||||
'['=119
|
||||
']'=120
|
||||
'('=121
|
||||
')'=122
|
||||
'<'=123
|
||||
'>'=124
|
||||
'&'=125
|
||||
'='=126
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,253 +1,259 @@
|
||||
WORKSPACE=1
|
||||
QUERY=2
|
||||
SPECIALIZE=3
|
||||
ROOT=4
|
||||
DOCUMENT=5
|
||||
FRAGMENTS=6
|
||||
VIEW=7
|
||||
MAX=8
|
||||
ALLOW=9
|
||||
POLL=10
|
||||
QUERYABLE=11
|
||||
RPC=12
|
||||
QUERY_REASON=13
|
||||
TYPE=14
|
||||
OBJECT=15
|
||||
STORABLE=16
|
||||
IMPLEMENTS=17
|
||||
REF=18
|
||||
FRAGMENT=19
|
||||
IMPORT=20
|
||||
EXTERNAL=21
|
||||
ATOM=22
|
||||
INTERFACE=23
|
||||
INTERFACES=24
|
||||
PACKAGE=25
|
||||
VALUE=26
|
||||
RELATION=27
|
||||
OPERATION=28
|
||||
FUNCTION=29
|
||||
CONSTRUCTOR=30
|
||||
CONSTRUCTS=31
|
||||
INPUT=32
|
||||
CONFORM=33
|
||||
AS=34
|
||||
BIND=35
|
||||
STATIC=36
|
||||
TO=37
|
||||
PRIVATE=38
|
||||
SHARED=39
|
||||
STATE=40
|
||||
EDGE=41
|
||||
PROJECTION=42
|
||||
WITH=43
|
||||
USING=44
|
||||
VIA=45
|
||||
MATERIALIZE=46
|
||||
IF=47
|
||||
ABSENT=48
|
||||
ON=49
|
||||
POLICY=50
|
||||
DEFAULT=51
|
||||
SOURCE=52
|
||||
REPOSITORY=53
|
||||
COMMIT=54
|
||||
REVISION=55
|
||||
SEMANTIC_MAJOR=56
|
||||
ON_DELETE=57
|
||||
RETAIN_OTHER=58
|
||||
KEYED=59
|
||||
PUBLIC_TRAVERSAL=60
|
||||
ID=61
|
||||
DOC=62
|
||||
MODE=63
|
||||
EMITS=64
|
||||
RECEIVER=65
|
||||
REQUIRES=66
|
||||
ANY=67
|
||||
GET=68
|
||||
SET=69
|
||||
WATCH=70
|
||||
START=71
|
||||
STOP=72
|
||||
READ=73
|
||||
WRITE=74
|
||||
RESOLVE=75
|
||||
CONNECT=76
|
||||
DISCONNECT=77
|
||||
CALL=78
|
||||
WATCH_START=79
|
||||
WATCH_STOP=80
|
||||
SUBSCRIBE=81
|
||||
UNSUBSCRIBE=82
|
||||
OPTIMISTIC_REGISTER=83
|
||||
CRDT=84
|
||||
OPTIONAL_ONE=85
|
||||
EXACTLY_ONE=86
|
||||
MANY_UNIQUE=87
|
||||
MANY=88
|
||||
ORDERED=89
|
||||
UNIT=90
|
||||
WATCH_HANDLE=91
|
||||
MESSAGE=92
|
||||
ATOM_REF=93
|
||||
INTERFACE_REF=94
|
||||
OPTIONAL=95
|
||||
LIST=96
|
||||
RECORD=97
|
||||
BOOL=98
|
||||
BYTES=99
|
||||
DOUBLE=100
|
||||
INT32=101
|
||||
INT64=102
|
||||
STRING=103
|
||||
UINT32=104
|
||||
UINT64=105
|
||||
TRUE=106
|
||||
FALSE=107
|
||||
NULL=108
|
||||
ARROW=109
|
||||
COLON=110
|
||||
SEMI=111
|
||||
COMMA=112
|
||||
DOT=113
|
||||
LBRACE=114
|
||||
RBRACE=115
|
||||
LBRACK=116
|
||||
RBRACK=117
|
||||
LPAREN=118
|
||||
RPAREN=119
|
||||
LT=120
|
||||
GT=121
|
||||
AMP=122
|
||||
EQUAL=123
|
||||
INTEGER=124
|
||||
JSON_NUMBER=125
|
||||
IDENTIFIER=126
|
||||
STRING_LITERAL=127
|
||||
LINE_COMMENT=128
|
||||
BLOCK_COMMENT=129
|
||||
WS=130
|
||||
RESULT_BYTES=2
|
||||
RPC_CALLS=3
|
||||
DEADLINE_MS=4
|
||||
QUERY=5
|
||||
SPECIALIZE=6
|
||||
ROOT=7
|
||||
DOCUMENT=8
|
||||
FRAGMENTS=9
|
||||
VIEW=10
|
||||
MAX=11
|
||||
ALLOW=12
|
||||
POLL=13
|
||||
QUERYABLE=14
|
||||
RPC=15
|
||||
QUERY_REASON=16
|
||||
TYPE=17
|
||||
OBJECT=18
|
||||
STORABLE=19
|
||||
IMPLEMENTS=20
|
||||
REF=21
|
||||
FRAGMENT=22
|
||||
IMPORT=23
|
||||
EXTERNAL=24
|
||||
ATOM=25
|
||||
INTERFACE=26
|
||||
INTERFACES=27
|
||||
PACKAGE=28
|
||||
VALUE=29
|
||||
RELATION=30
|
||||
OPERATION=31
|
||||
FUNCTION=32
|
||||
CONSTRUCTOR=33
|
||||
CONSTRUCTS=34
|
||||
INPUT=35
|
||||
CONFORM=36
|
||||
AS=37
|
||||
BIND=38
|
||||
STATIC=39
|
||||
TO=40
|
||||
PRIVATE=41
|
||||
SHARED=42
|
||||
STATE=43
|
||||
EDGE=44
|
||||
PROJECTION=45
|
||||
WITH=46
|
||||
USING=47
|
||||
VIA=48
|
||||
MATERIALIZE=49
|
||||
IF=50
|
||||
ABSENT=51
|
||||
ON=52
|
||||
POLICY=53
|
||||
DEFAULT=54
|
||||
SOURCE=55
|
||||
REPOSITORY=56
|
||||
COMMIT=57
|
||||
REVISION=58
|
||||
SEMANTIC_MAJOR=59
|
||||
ON_DELETE=60
|
||||
RETAIN_OTHER=61
|
||||
KEYED=62
|
||||
PUBLIC_TRAVERSAL=63
|
||||
ID=64
|
||||
DOC=65
|
||||
MODE=66
|
||||
EMITS=67
|
||||
RECEIVER=68
|
||||
REQUIRES=69
|
||||
ANY=70
|
||||
GET=71
|
||||
SET=72
|
||||
WATCH=73
|
||||
START=74
|
||||
STOP=75
|
||||
READ=76
|
||||
WRITE=77
|
||||
RESOLVE=78
|
||||
CONNECT=79
|
||||
DISCONNECT=80
|
||||
CALL=81
|
||||
WATCH_START=82
|
||||
WATCH_STOP=83
|
||||
SUBSCRIBE=84
|
||||
UNSUBSCRIBE=85
|
||||
OPTIMISTIC_REGISTER=86
|
||||
CRDT=87
|
||||
OPTIONAL_ONE=88
|
||||
EXACTLY_ONE=89
|
||||
MANY_UNIQUE=90
|
||||
MANY=91
|
||||
ORDERED=92
|
||||
UNIT=93
|
||||
WATCH_HANDLE=94
|
||||
MESSAGE=95
|
||||
ATOM_REF=96
|
||||
INTERFACE_REF=97
|
||||
OPTIONAL=98
|
||||
LIST=99
|
||||
RECORD=100
|
||||
BOOL=101
|
||||
BYTES=102
|
||||
DOUBLE=103
|
||||
INT32=104
|
||||
INT64=105
|
||||
STRING=106
|
||||
UINT32=107
|
||||
UINT64=108
|
||||
TRUE=109
|
||||
FALSE=110
|
||||
NULL=111
|
||||
ARROW=112
|
||||
COLON=113
|
||||
SEMI=114
|
||||
COMMA=115
|
||||
DOT=116
|
||||
LBRACE=117
|
||||
RBRACE=118
|
||||
LBRACK=119
|
||||
RBRACK=120
|
||||
LPAREN=121
|
||||
RPAREN=122
|
||||
LT=123
|
||||
GT=124
|
||||
AMP=125
|
||||
EQUAL=126
|
||||
INTEGER=127
|
||||
JSON_NUMBER=128
|
||||
IDENTIFIER=129
|
||||
STRING_LITERAL=130
|
||||
LINE_COMMENT=131
|
||||
BLOCK_COMMENT=132
|
||||
WS=133
|
||||
'workspace'=1
|
||||
'query'=2
|
||||
'specialize'=3
|
||||
'root'=4
|
||||
'document'=5
|
||||
'fragments'=6
|
||||
'view'=7
|
||||
'max'=8
|
||||
'allow'=9
|
||||
'poll'=10
|
||||
'queryable'=11
|
||||
'rpc'=12
|
||||
'query-reason'=13
|
||||
'type'=14
|
||||
'object'=15
|
||||
'storable'=16
|
||||
'implements'=17
|
||||
'ref'=18
|
||||
'fragment'=19
|
||||
'import'=20
|
||||
'external'=21
|
||||
'atom'=22
|
||||
'interface'=23
|
||||
'interfaces'=24
|
||||
'package'=25
|
||||
'value'=26
|
||||
'relation'=27
|
||||
'operation'=28
|
||||
'function'=29
|
||||
'constructor'=30
|
||||
'constructs'=31
|
||||
'input'=32
|
||||
'conform'=33
|
||||
'as'=34
|
||||
'bind'=35
|
||||
'static'=36
|
||||
'to'=37
|
||||
'private'=38
|
||||
'shared'=39
|
||||
'state'=40
|
||||
'edge'=41
|
||||
'projection'=42
|
||||
'with'=43
|
||||
'using'=44
|
||||
'via'=45
|
||||
'materialize'=46
|
||||
'if'=47
|
||||
'absent'=48
|
||||
'on'=49
|
||||
'policy'=50
|
||||
'default'=51
|
||||
'source'=52
|
||||
'repository'=53
|
||||
'commit'=54
|
||||
'revision'=55
|
||||
'semantic-major'=56
|
||||
'on-delete'=57
|
||||
'retain-other'=58
|
||||
'keyed'=59
|
||||
'public-traversal'=60
|
||||
'id'=61
|
||||
'doc'=62
|
||||
'mode'=63
|
||||
'emits'=64
|
||||
'receiver'=65
|
||||
'requires'=66
|
||||
'any'=67
|
||||
'get'=68
|
||||
'set'=69
|
||||
'watch'=70
|
||||
'start'=71
|
||||
'stop'=72
|
||||
'read'=73
|
||||
'write'=74
|
||||
'resolve'=75
|
||||
'connect'=76
|
||||
'disconnect'=77
|
||||
'call'=78
|
||||
'watch-start'=79
|
||||
'watch-stop'=80
|
||||
'subscribe'=81
|
||||
'unsubscribe'=82
|
||||
'optimistic-register'=83
|
||||
'crdt'=84
|
||||
'optional-one'=85
|
||||
'exactly-one'=86
|
||||
'many-unique'=87
|
||||
'many'=88
|
||||
'ordered'=89
|
||||
'unit'=90
|
||||
'watch-handle'=91
|
||||
'message'=92
|
||||
'atom-ref'=93
|
||||
'interface-ref'=94
|
||||
'optional'=95
|
||||
'list'=96
|
||||
'record'=97
|
||||
'bool'=98
|
||||
'bytes'=99
|
||||
'double'=100
|
||||
'int32'=101
|
||||
'int64'=102
|
||||
'string'=103
|
||||
'uint32'=104
|
||||
'uint64'=105
|
||||
'true'=106
|
||||
'false'=107
|
||||
'null'=108
|
||||
'->'=109
|
||||
':'=110
|
||||
';'=111
|
||||
','=112
|
||||
'.'=113
|
||||
'{'=114
|
||||
'}'=115
|
||||
'['=116
|
||||
']'=117
|
||||
'('=118
|
||||
')'=119
|
||||
'<'=120
|
||||
'>'=121
|
||||
'&'=122
|
||||
'='=123
|
||||
'result-bytes'=2
|
||||
'rpc-calls'=3
|
||||
'deadline-ms'=4
|
||||
'query'=5
|
||||
'specialize'=6
|
||||
'root'=7
|
||||
'document'=8
|
||||
'fragments'=9
|
||||
'view'=10
|
||||
'max'=11
|
||||
'allow'=12
|
||||
'poll'=13
|
||||
'queryable'=14
|
||||
'rpc'=15
|
||||
'query-reason'=16
|
||||
'type'=17
|
||||
'object'=18
|
||||
'storable'=19
|
||||
'implements'=20
|
||||
'ref'=21
|
||||
'fragment'=22
|
||||
'import'=23
|
||||
'external'=24
|
||||
'atom'=25
|
||||
'interface'=26
|
||||
'interfaces'=27
|
||||
'package'=28
|
||||
'value'=29
|
||||
'relation'=30
|
||||
'operation'=31
|
||||
'function'=32
|
||||
'constructor'=33
|
||||
'constructs'=34
|
||||
'input'=35
|
||||
'conform'=36
|
||||
'as'=37
|
||||
'bind'=38
|
||||
'static'=39
|
||||
'to'=40
|
||||
'private'=41
|
||||
'shared'=42
|
||||
'state'=43
|
||||
'edge'=44
|
||||
'projection'=45
|
||||
'with'=46
|
||||
'using'=47
|
||||
'via'=48
|
||||
'materialize'=49
|
||||
'if'=50
|
||||
'absent'=51
|
||||
'on'=52
|
||||
'policy'=53
|
||||
'default'=54
|
||||
'source'=55
|
||||
'repository'=56
|
||||
'commit'=57
|
||||
'revision'=58
|
||||
'semantic-major'=59
|
||||
'on-delete'=60
|
||||
'retain-other'=61
|
||||
'keyed'=62
|
||||
'public-traversal'=63
|
||||
'id'=64
|
||||
'doc'=65
|
||||
'mode'=66
|
||||
'emits'=67
|
||||
'receiver'=68
|
||||
'requires'=69
|
||||
'any'=70
|
||||
'get'=71
|
||||
'set'=72
|
||||
'watch'=73
|
||||
'start'=74
|
||||
'stop'=75
|
||||
'read'=76
|
||||
'write'=77
|
||||
'resolve'=78
|
||||
'connect'=79
|
||||
'disconnect'=80
|
||||
'call'=81
|
||||
'watch-start'=82
|
||||
'watch-stop'=83
|
||||
'subscribe'=84
|
||||
'unsubscribe'=85
|
||||
'optimistic-register'=86
|
||||
'crdt'=87
|
||||
'optional-one'=88
|
||||
'exactly-one'=89
|
||||
'many-unique'=90
|
||||
'many'=91
|
||||
'ordered'=92
|
||||
'unit'=93
|
||||
'watch-handle'=94
|
||||
'message'=95
|
||||
'atom-ref'=96
|
||||
'interface-ref'=97
|
||||
'optional'=98
|
||||
'list'=99
|
||||
'record'=100
|
||||
'bool'=101
|
||||
'bytes'=102
|
||||
'double'=103
|
||||
'int32'=104
|
||||
'int64'=105
|
||||
'string'=106
|
||||
'uint32'=107
|
||||
'uint64'=108
|
||||
'true'=109
|
||||
'false'=110
|
||||
'null'=111
|
||||
'->'=112
|
||||
':'=113
|
||||
';'=114
|
||||
','=115
|
||||
'.'=116
|
||||
'{'=117
|
||||
'}'=118
|
||||
'['=119
|
||||
']'=120
|
||||
'('=121
|
||||
')'=122
|
||||
'<'=123
|
||||
'>'=124
|
||||
'&'=125
|
||||
'='=126
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1307,12 +1307,12 @@ const lowerPackage = (
|
||||
if (parameters.length) {
|
||||
const use = identifier(clause.identifier(1));
|
||||
const reason = stringValue(clause.stringLiteral()!);
|
||||
if (!["select", "predicate", "order"].includes(use) || !reason.trim())
|
||||
if (!["select", "predicate", "order", "aggregate", "group", "distinct"].includes(use) || !reason.trim())
|
||||
loweringIssue(
|
||||
state,
|
||||
clause,
|
||||
"invalid-query-allowance",
|
||||
"Expected select/predicate/order and a nonempty reason",
|
||||
"Expected select/predicate/order/aggregate/group/distinct and a nonempty reason",
|
||||
);
|
||||
templateAllowances.push({
|
||||
interface: state.types.interface(clause.interfaceType()!),
|
||||
@@ -1329,12 +1329,16 @@ const lowerPackage = (
|
||||
const member = contract?.members.find((entry) => entry.displayName === identifier(clause.identifier(0)));
|
||||
const use = identifier(clause.identifier(1));
|
||||
const reason = stringValue(clause.stringLiteral()!);
|
||||
if (!member || !["select", "predicate", "order"].includes(use) || !reason.trim())
|
||||
if (
|
||||
!member ||
|
||||
!["select", "predicate", "order", "aggregate", "group", "distinct"].includes(use) ||
|
||||
!reason.trim()
|
||||
)
|
||||
loweringIssue(
|
||||
state,
|
||||
clause,
|
||||
"invalid-query-allowance",
|
||||
"Query allowances name an exact member, use (select/predicate/order), and nonempty reason",
|
||||
"Query allowances name an exact member, use (select/predicate/order/aggregate/group/distinct), and nonempty reason",
|
||||
);
|
||||
else
|
||||
declaration.allowances.push({
|
||||
|
||||
+189
-8
File diff suppressed because one or more lines are too long
+857
-4
File diff suppressed because one or more lines are too long
+71
-52
@@ -34,6 +34,8 @@ import {
|
||||
type InterfaceRevisionId,
|
||||
} from "../capability-model/types.js";
|
||||
import { querySchema } from "./schema.js";
|
||||
import { objectRow } from "./relational-schema.js";
|
||||
import { relationalCompiler } from "./relational-compile.js";
|
||||
import {
|
||||
QueryCompileError,
|
||||
type QueryDeclaration,
|
||||
@@ -58,26 +60,6 @@ const fail = (code: string, message: string, node?: ASTNode): never => {
|
||||
: undefined,
|
||||
);
|
||||
};
|
||||
const shapeScalar = (name: string): ValueType => {
|
||||
switch (name) {
|
||||
case "Boolean":
|
||||
return valueType.bool;
|
||||
case "Int":
|
||||
return valueType.int32;
|
||||
case "Int64":
|
||||
return valueType.int64;
|
||||
case "UInt32":
|
||||
return valueType.uint32;
|
||||
case "UInt64":
|
||||
return valueType.uint64;
|
||||
case "Float":
|
||||
return valueType.double;
|
||||
case "Bytes":
|
||||
return valueType.bytes;
|
||||
default:
|
||||
return valueType.string;
|
||||
}
|
||||
};
|
||||
|
||||
/** Only immutable repository sources call this. Runtime requests never compile documents. */
|
||||
export async function compileQuery(
|
||||
@@ -104,7 +86,7 @@ export async function compileQuery(
|
||||
definitions.push(...document.definitions);
|
||||
}
|
||||
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
|
||||
const generated = querySchema(declaration, interfaces);
|
||||
const generated = querySchema(declaration, interfaces, document);
|
||||
const operations = definitions.filter((entry) => entry.kind === "OperationDefinition");
|
||||
if (
|
||||
operations.length !== 1 ||
|
||||
@@ -114,6 +96,8 @@ export async function compileQuery(
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "Exactly one named query matching the declaration is required");
|
||||
visit(document, {
|
||||
Field(node) {
|
||||
if (node.name.value === "_unavailable")
|
||||
fail("QUERY_AGGREGATE_TYPE", "No supported field exists at this operator path", node);
|
||||
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
|
||||
},
|
||||
Directive(node) {
|
||||
@@ -130,6 +114,11 @@ export async function compileQuery(
|
||||
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
|
||||
}
|
||||
const effects = new Map<string, QueryFieldEffect>();
|
||||
const fragments = new Map(
|
||||
definitions
|
||||
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
|
||||
.map((entry) => [entry.name.value, entry]),
|
||||
);
|
||||
const mark = (id: InterfaceRevisionId, name: string, use: QueryUse, node: ASTNode) => {
|
||||
const contract = generated.contracts.get(id)!;
|
||||
const member = contract.members.find((entry) => entry.displayName === name);
|
||||
@@ -167,7 +156,13 @@ export async function compileQuery(
|
||||
node,
|
||||
);
|
||||
};
|
||||
const relational = relationalCompiler(generated, declaration, mark, fragments);
|
||||
const compiledPredicates = new WeakMap<ASTNode, import("./relational.js").QueryPredicate>();
|
||||
const inputEffects = (id: InterfaceRevisionId, value: ValueNode, use: "predicate" | "order") => {
|
||||
if (use === "predicate") {
|
||||
relational.predicate(objectRow(id), value);
|
||||
return;
|
||||
}
|
||||
if (value.kind === "Variable") {
|
||||
// A dynamic filter may name any field in its declared input type.
|
||||
for (const member of generated.contracts.get(id)!.members)
|
||||
@@ -175,12 +170,10 @@ export async function compileQuery(
|
||||
} else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use));
|
||||
else if (value.kind === "ObjectValue")
|
||||
for (const field of value.fields) {
|
||||
if (use === "predicate" && ["and", "or", "not"].includes(field.name.value)) inputEffects(id, field.value, use);
|
||||
else mark(id, field.name.value, use, field);
|
||||
mark(id, field.name.value, use, field);
|
||||
}
|
||||
};
|
||||
const info = new TypeInfo(generated.schema);
|
||||
let hasContinuation = false;
|
||||
visit(
|
||||
document,
|
||||
visitWithTypeInfo(info, {
|
||||
@@ -200,9 +193,9 @@ export async function compileQuery(
|
||||
if (bounds[0]!.name.value === "all" && node.arguments?.some((argument) => argument.name.value === "after"))
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all does not accept a continuation", node);
|
||||
for (const argument of node.arguments ?? []) {
|
||||
if (argument.name.value === "where") inputEffects(target, argument.value, "predicate");
|
||||
if (argument.name.value === "where")
|
||||
compiledPredicates.set(node, relational.predicate(objectRow(target), argument.value));
|
||||
if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order");
|
||||
if (argument.name.value === "after") hasContinuation = true;
|
||||
if (
|
||||
["first", "all"].includes(argument.name.value) &&
|
||||
argument.value.kind !== "Variable" &&
|
||||
@@ -216,6 +209,33 @@ export async function compileQuery(
|
||||
argument,
|
||||
);
|
||||
}
|
||||
if (node.arguments?.some((a) => a.name.value === "after")) {
|
||||
const rpcOrder = (value: ValueNode): boolean =>
|
||||
value.kind === "Variable"
|
||||
? generated.contracts
|
||||
.get(target)!
|
||||
.members.some((m) => m.kind === "value" && m.queryRead?.execution === "rpc-permitted")
|
||||
: value.kind === "ListValue"
|
||||
? value.values.some(rpcOrder)
|
||||
: value.kind === "ObjectValue" &&
|
||||
value.fields.some((f) =>
|
||||
generated.contracts
|
||||
.get(target)!
|
||||
.members.some(
|
||||
(m) =>
|
||||
m.displayName === f.name.value &&
|
||||
m.kind === "value" &&
|
||||
m.queryRead?.execution === "rpc-permitted",
|
||||
),
|
||||
);
|
||||
const order = node.arguments.find((a) => a.name.value === "orderBy");
|
||||
if (relational.needsRpc(compiledPredicates.get(node)) || (order && rpcOrder(order.value)))
|
||||
fail(
|
||||
"QUERY_UNSUPPORTED_FEATURE",
|
||||
"RPC predicates/order require a bounded first/all window without continuation",
|
||||
node,
|
||||
);
|
||||
}
|
||||
},
|
||||
FragmentSpread(node) {
|
||||
const fragment = definitions.find(
|
||||
@@ -226,18 +246,6 @@ export async function compileQuery(
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (
|
||||
hasContinuation &&
|
||||
[...effects.values()].some(
|
||||
(effect) => effect.execution === "rpc-permitted" && effect.uses.some((use) => use !== "select"),
|
||||
)
|
||||
)
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "RPC predicates/order support bounded first windows, not continuation");
|
||||
const fragments = new Map(
|
||||
definitions
|
||||
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
|
||||
.map((entry) => [entry.name.value, entry]),
|
||||
);
|
||||
let expandedFields = 0;
|
||||
// GraphQL merges repeated response keys. In particular, fragments may each
|
||||
// contribute different children of one relationship; last-write-wins would
|
||||
@@ -280,16 +288,6 @@ export async function compileQuery(
|
||||
depth + 1,
|
||||
conditional || !!selection.directives?.length,
|
||||
);
|
||||
if (selection.name.value === "_qx") {
|
||||
const contract = generated.byName.get(type.name)!;
|
||||
const metadataFields: Record<string, ValueType> = {};
|
||||
for (const metadata of selection.selectionSet?.selections ?? []) {
|
||||
if (metadata.kind !== "Field" || metadata.name.value !== "ref")
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "Select _qx.ref directly", metadata);
|
||||
else metadataFields[metadata.alias?.value ?? "ref"] = valueType.interfaceRef(contract.revisionId);
|
||||
}
|
||||
fields[key] = { kind: "record", fields: metadataFields };
|
||||
}
|
||||
if ((conditional || selection.directives?.length) && fields[key]!.kind !== "optional")
|
||||
fields[key] = valueType.optional(fields[key]!);
|
||||
if (previous) fields[key] = mergeOutput(previous, fields[key]!);
|
||||
@@ -298,7 +296,10 @@ export async function compileQuery(
|
||||
if (selections) add(selections, conditional);
|
||||
return { kind: "record", fields };
|
||||
}
|
||||
return shapeScalar(getNamedType(type)!.name);
|
||||
return (
|
||||
generated.scalarTypes.get(getNamedType(type)!.name) ??
|
||||
fail("QUERY_TYPE", `Unsupported query scalar ${getNamedType(type)!.name}`)
|
||||
);
|
||||
};
|
||||
const variables: Record<string, ValueType> = {};
|
||||
const inputShape = (type: GraphQLType, seen = new Set<string>()): ValueType => {
|
||||
@@ -324,7 +325,9 @@ export async function compileQuery(
|
||||
),
|
||||
};
|
||||
}
|
||||
if (isScalarType(type) || isEnumType(type)) return shapeScalar(type.name);
|
||||
if (isEnumType(type)) return valueType.string;
|
||||
if (isScalarType(type))
|
||||
return generated.scalarTypes.get(type.name) ?? fail("QUERY_TYPE", `Unsupported query scalar ${type.name}`);
|
||||
return fail("QUERY_VARIABLE_TYPE", "Unsupported variable type");
|
||||
};
|
||||
for (const variable of operations[0]!.variableDefinitions ?? [])
|
||||
@@ -370,6 +373,19 @@ export async function compileQuery(
|
||||
const contract = generated.byName.get(parent.name);
|
||||
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
|
||||
const type = getNamedType(parent.getFields()[node.name.value]!.type);
|
||||
const role = generated.relational.roles.get(parent.name);
|
||||
const relationship =
|
||||
role?.kind === "relations"
|
||||
? generated.relational.relations(role.row).find((m) => m.displayName === node.name.value)
|
||||
: undefined;
|
||||
const plan =
|
||||
relationship && role?.row.kind === "object" && node.selectionSet
|
||||
? relational.plan(role.row.interfaceRevisionId, relationship, node.selectionSet, (set, name) => {
|
||||
const t = generated.schema.getType(name);
|
||||
if (!t || !isObjectType(t)) return fail("QUERY_TYPE", `Missing generated output type ${name}`, node);
|
||||
return selection(set, t);
|
||||
})
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
name: node.name.value,
|
||||
@@ -381,15 +397,18 @@ export async function compileQuery(
|
||||
(node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]),
|
||||
),
|
||||
selection: node.selectionSet && isObjectType(type) ? selection(node.selectionSet, type) : [],
|
||||
...(plan ? { relational: plan } : {}),
|
||||
...(compiledPredicates.has(node) ? { predicate: compiledPredicates.get(node)! } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
const normalized = print(document),
|
||||
schema = printSchema(generated.schema);
|
||||
const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!);
|
||||
const definitionDigest = createHash("sha256")
|
||||
.update(
|
||||
canonicalJson({
|
||||
semantics: 1,
|
||||
semantics: 2,
|
||||
declaration,
|
||||
normalized,
|
||||
interfaces: [...generated.byName.values()].sort((a, b) =>
|
||||
@@ -406,7 +425,7 @@ export async function compileQuery(
|
||||
variables: { kind: "record", fields: variables },
|
||||
output: result,
|
||||
effects: [...effects.values()],
|
||||
selection: selection(operations[0]!.selectionSet, generated.schema.getQueryType()!),
|
||||
selection: selections,
|
||||
variableDefaults: Object.fromEntries(
|
||||
(operations[0]!.variableDefinitions ?? [])
|
||||
.filter((entry) => entry.defaultValue)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { LinkedQuery } from "./link.js";
|
||||
import type { QueryArgument, QuerySelection } from "./types.js";
|
||||
import type { WorkspaceRevision } from "../capability-model/types.js";
|
||||
import { relationalWire } from "./relational-proto.js";
|
||||
|
||||
const argument = (entry: QueryArgument): WireArgument => {
|
||||
switch (entry.kind) {
|
||||
@@ -34,8 +35,12 @@ const selection = (entry: QuerySelection): import("../gen/camino/schema_pb.js").
|
||||
conditions: entry.conditions.map((condition) => ({ include: condition.include, value: argument(condition.value) })),
|
||||
arguments: Object.fromEntries(Object.entries(entry.arguments).map(([k, v]) => [k, argument(v)])),
|
||||
selection: entry.selection.map(selection),
|
||||
relational: entry.relational && relationalWire(argument, selection).plan(entry.relational),
|
||||
predicate: entry.predicate && relationalWire(argument, selection).predicate(entry.predicate),
|
||||
});
|
||||
|
||||
export const querySelectionToWire = selection;
|
||||
|
||||
export const queryRuntimePlan = (linked: LinkedQuery, workspace: WorkspaceRevision) =>
|
||||
toJson(
|
||||
InstalledQuerySchema,
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import type { ASTNode, FieldNode, FragmentDefinitionNode, SelectionSetNode, ValueNode } from "graphql";
|
||||
import {
|
||||
valueType,
|
||||
type InterfaceRevisionId,
|
||||
type RelationshipInterfaceMember,
|
||||
type ValueType,
|
||||
} from "../capability-model/types.js";
|
||||
import {
|
||||
QueryCompileError,
|
||||
type QueryArgument,
|
||||
type QueryDeclaration,
|
||||
type QuerySelection,
|
||||
type QueryUse,
|
||||
} from "./types.js";
|
||||
import {
|
||||
aggregateType,
|
||||
queryKeyType,
|
||||
type AggregateOperator,
|
||||
type QueryAggregatePredicate,
|
||||
type QueryEffectRecorder,
|
||||
type QueryExpression,
|
||||
type QueryKey,
|
||||
type QueryPathStep,
|
||||
type QueryPredicate,
|
||||
type QueryReduction,
|
||||
type QueryRelationalPlan,
|
||||
type QueryRelationalTerminal,
|
||||
type QueryRow,
|
||||
} from "./relational.js";
|
||||
import { objectRow } from "./relational-schema.js";
|
||||
|
||||
export function queryArgument(node: ValueNode): QueryArgument {
|
||||
if (node.kind === "Variable") return { kind: "variable", name: node.name.value };
|
||||
if (node.kind === "ListValue") return { kind: "list", values: node.values.map(queryArgument) };
|
||||
if (node.kind === "ObjectValue")
|
||||
return {
|
||||
kind: "object",
|
||||
fields: Object.fromEntries(node.fields.map((f) => [f.name.value, queryArgument(f.value)])),
|
||||
};
|
||||
return { kind: "literal", value: node.kind === "NullValue" ? null : node.value };
|
||||
}
|
||||
type Environment = ReturnType<typeof import("./schema.js").querySchema>;
|
||||
type Availability = readonly QueryKey[] | undefined;
|
||||
function fail(code: string, message: string, node: ASTNode): never {
|
||||
const token = node.loc?.startToken;
|
||||
throw new QueryCompileError(
|
||||
code,
|
||||
message,
|
||||
token ? { file: node.loc!.source.name, line: token.line, column: token.column } : undefined,
|
||||
);
|
||||
}
|
||||
const pairs = (node: ValueNode): readonly import("graphql").ObjectFieldNode[] =>
|
||||
node.kind === "ObjectValue"
|
||||
? node.fields
|
||||
: fail("QUERY_UNSUPPORTED_FEATURE", "Query structure must be a literal object", node);
|
||||
const ops = new Set(["eq", "in", "isNull", "lt", "lte", "gt", "gte"]);
|
||||
const prefix = (a: readonly string[], b: readonly string[]) => a.every((part, i) => part === b[i]);
|
||||
|
||||
export function relationalCompiler(
|
||||
env: Environment,
|
||||
declaration: QueryDeclaration,
|
||||
mark: QueryEffectRecorder,
|
||||
fragments: Map<string, FragmentDefinitionNode>,
|
||||
) {
|
||||
const rowFields = env.relational.fields;
|
||||
const relationships = env.relational.relations;
|
||||
const needsRpc = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if ("leaf" in value) {
|
||||
const leaf = (value as QueryExpression).leaf;
|
||||
if (leaf.kind === "field")
|
||||
return (
|
||||
env.contracts
|
||||
.get(leaf.interfaceRevisionId)
|
||||
?.members.some(
|
||||
(m) => m.id === leaf.memberId && m.kind === "value" && m.queryRead?.execution === "rpc-permitted",
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
return Object.values(value).some(needsRpc);
|
||||
};
|
||||
let stageCount = 0;
|
||||
const fields = (set: SelectionSetNode): FieldNode[] =>
|
||||
set.selections.flatMap((n) =>
|
||||
n.kind === "Field" ? [n] : n.kind === "FragmentSpread" ? fields(fragments.get(n.name.value)!.selectionSet) : [],
|
||||
);
|
||||
const requireAvailable = (path: string[], available: Availability, node: ASTNode) => {
|
||||
if (!available) return;
|
||||
const ok = available.some((key) => {
|
||||
if (key.path.join(".") === path.join(".")) return true;
|
||||
const last = key.expression.leaf.kind;
|
||||
if (last !== "ref" && last !== "entry") return false;
|
||||
const base = key.path.slice(0, -2);
|
||||
if (!prefix(base, path)) return false;
|
||||
// Object identity does not determine which incoming membership carried it.
|
||||
return (
|
||||
last === "entry" || !(path[base.length] === "_qx" && ["entry", "mapKey"].includes(path[base.length + 1] ?? ""))
|
||||
);
|
||||
});
|
||||
if (!ok)
|
||||
fail(
|
||||
"QUERY_DISTINCT_FIELD_UNAVAILABLE",
|
||||
`${path.join(".")} was dropped by distinct; retain its identity or key explicitly`,
|
||||
node,
|
||||
);
|
||||
};
|
||||
const walk = (
|
||||
root: QueryRow,
|
||||
names: string[],
|
||||
uses: QueryUse[],
|
||||
node: ASTNode,
|
||||
): { row: QueryRow; path: QueryPathStep[]; nullable: boolean } => {
|
||||
let row = root,
|
||||
nullable = false;
|
||||
const path: QueryPathStep[] = [];
|
||||
for (const name of names) {
|
||||
if (row.kind === "pair") {
|
||||
if (name !== "source" && name !== "target")
|
||||
return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown row binding ${name}`, node);
|
||||
path.push({ kind: name });
|
||||
row = row[name];
|
||||
continue;
|
||||
}
|
||||
const relation = relationships(row).find((m) => m.displayName === name);
|
||||
if (!relation) return fail("QUERY_FIELD_NOT_QUERYABLE", `Expected queryable relationship ${name}`, node);
|
||||
if (relation.cardinality === "many" || relation.cardinality === "many-unique")
|
||||
return fail(
|
||||
"QUERY_EXPANSION_REQUIRED",
|
||||
`${name} is to-many; explicitly expand it, quantify it or aggregate it`,
|
||||
node,
|
||||
);
|
||||
for (const use of uses) mark(row.interfaceRevisionId, name, use, node);
|
||||
const target = env.target(relation),
|
||||
optional = relation.cardinality === "optional-one";
|
||||
path.push({
|
||||
kind: "relation",
|
||||
interfaceRevisionId: row.interfaceRevisionId,
|
||||
memberId: relation.id,
|
||||
targetInterfaceRevisionId: target,
|
||||
optional,
|
||||
});
|
||||
nullable ||= optional;
|
||||
row = objectRow(target);
|
||||
}
|
||||
if (path.length > declaration.budgets.depth) fail("QUERY_DEPTH_LIMIT", "Expression path exceeds query depth", node);
|
||||
return { row, path, nullable };
|
||||
};
|
||||
const expression = (
|
||||
root: QueryRow,
|
||||
names: string[],
|
||||
uses: QueryUse[],
|
||||
node: ASTNode,
|
||||
available?: Availability,
|
||||
): QueryExpression => {
|
||||
requireAvailable(names, available, node);
|
||||
const meta = names.at(-2) === "_qx";
|
||||
const { row, path, nullable } = walk(root, names.slice(0, meta ? -2 : -1), uses, node);
|
||||
const name = names.at(-1)!;
|
||||
let type: ValueType, leaf: QueryExpression["leaf"];
|
||||
if (meta) {
|
||||
if (name === "ref" && row.kind === "object") {
|
||||
type = valueType.interfaceRef(row.interfaceRevisionId);
|
||||
leaf = { kind: "ref", interfaceRevisionId: row.interfaceRevisionId };
|
||||
} else if (name === "entry" && (row.kind === "pair" || row.membership)) {
|
||||
type = valueType.string;
|
||||
leaf = { kind: "entry" };
|
||||
} else if (name === "mapKey" && row.kind === "object" && row.membership?.keyType) {
|
||||
type =
|
||||
row.membership.keyType === "boolean"
|
||||
? valueType.bool
|
||||
: row.membership.keyType === "int64"
|
||||
? valueType.int64
|
||||
: valueType.string;
|
||||
leaf = { kind: "mapKey" };
|
||||
} else return fail("QUERY_KEY_INVALID", `Unavailable metadata ${names.join(".")}`, node);
|
||||
} else {
|
||||
if (row.kind !== "object")
|
||||
return fail("QUERY_FIELD_NOT_QUERYABLE", `Expected source or target, not ${name}`, node);
|
||||
const field = rowFields(row).get(name);
|
||||
if (!field || field.kind !== "leaf") {
|
||||
if (relationships(row).some((m) => m.displayName === name))
|
||||
fail("QUERY_EXPANSION_REQUIRED", `${name} is not a scalar operand`, node);
|
||||
return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown queryable scalar ${name}`, node);
|
||||
}
|
||||
for (const use of uses) mark(row.interfaceRevisionId, name, use, node);
|
||||
const member = env.contracts.get(row.interfaceRevisionId)!.members.find((m) => m.displayName === name)!;
|
||||
type = field.type;
|
||||
leaf = { kind: "field", interfaceRevisionId: row.interfaceRevisionId, memberId: member.id };
|
||||
}
|
||||
return { path, leaf, type: nullable && type.kind !== "optional" ? valueType.optional(type) : type };
|
||||
};
|
||||
const keyList = (row: QueryRow, node: ValueNode, use: "group" | "distinct", available?: Availability): QueryKey[] => {
|
||||
const result: QueryKey[] = [];
|
||||
const visit = (node: ValueNode, path: string[]) => {
|
||||
if (node.kind === "BooleanValue") {
|
||||
if (!node.value) fail("QUERY_KEY_INVALID", "Key selector leaves must be literal true", node);
|
||||
const expr = expression(row, path, [use], node, available);
|
||||
if (!queryKeyType(expr.type)) fail("QUERY_KEY_INVALID", `Unsupported key ${path.join(".")}`, node);
|
||||
result.push({ path, expression: expr });
|
||||
return;
|
||||
}
|
||||
for (const field of pairs(node)) visit(field.value, [...path, field.name.value]);
|
||||
};
|
||||
visit(node, []);
|
||||
if (!result.length) fail("QUERY_KEY_INVALID", "Key selectors must not be empty", node);
|
||||
return result.sort((a, b) => JSON.stringify(a.expression).localeCompare(JSON.stringify(b.expression)));
|
||||
};
|
||||
const comparisons = (
|
||||
node: ValueNode,
|
||||
make: (
|
||||
operator: Extract<QueryPredicate, { kind: "compare" }>["operator"],
|
||||
value: QueryArgument,
|
||||
) => QueryPredicate | QueryAggregatePredicate,
|
||||
) =>
|
||||
pairs(node).map((field) => {
|
||||
if (!ops.has(field.name.value))
|
||||
return fail("QUERY_PREDICATE_INVALID", `Unsupported comparison ${field.name.value}`, field);
|
||||
if (field.name.value === "in" && field.value.kind === "ListValue" && field.value.values.length > 1000)
|
||||
fail("QUERY_WORK_LIMIT", "in supports at most 1000 operands", field);
|
||||
return make(
|
||||
field.name.value as Extract<QueryPredicate, { kind: "compare" }>["operator"],
|
||||
queryArgument(field.value),
|
||||
);
|
||||
});
|
||||
const reduction = (
|
||||
row: QueryRow,
|
||||
op: AggregateOperator,
|
||||
path: string[],
|
||||
node: ASTNode,
|
||||
uses: QueryUse[],
|
||||
available?: Availability,
|
||||
): QueryReduction => {
|
||||
if (op === "count") return { operator: op, type: aggregateType(op) };
|
||||
const operand = expression(row, path, ["aggregate", ...uses], node, available);
|
||||
return { operator: op, operand, type: aggregateType(op, operand.type) };
|
||||
};
|
||||
const aggregatePredicate = (
|
||||
row: QueryRow,
|
||||
node: ValueNode,
|
||||
keys: QueryKey[] = [],
|
||||
available?: Availability,
|
||||
): QueryAggregatePredicate => {
|
||||
const children: QueryAggregatePredicate[] = [];
|
||||
for (const field of pairs(node)) {
|
||||
const name = field.name.value;
|
||||
if (name === "and" || name === "or") {
|
||||
if (field.value.kind !== "ListValue")
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "Boolean predicate structure is fixed in the document", field);
|
||||
children.push({
|
||||
kind: name,
|
||||
children: field.value.values.map((v) => aggregatePredicate(row, v, keys, available)),
|
||||
});
|
||||
} else if (name === "not")
|
||||
children.push({ kind: "not", child: aggregatePredicate(row, field.value, keys, available) });
|
||||
else if (name === "count")
|
||||
children.push(
|
||||
...(comparisons(field.value, (operator, value) => ({
|
||||
kind: "compare",
|
||||
expression: reduction(row, "count", [], field, []),
|
||||
operator,
|
||||
value,
|
||||
})) as QueryAggregatePredicate[]),
|
||||
);
|
||||
else {
|
||||
const visit = (n: ValueNode, path: string[]) => {
|
||||
const entries = pairs(n);
|
||||
if (entries.some((f) => ops.has(f.name.value))) {
|
||||
if (name === "group" && !keys.some((k) => k.path.join(".") === path.join(".")))
|
||||
fail("QUERY_GROUP_FIELD_UNAVAILABLE", `${path.join(".")} is not a grouping key`, n);
|
||||
const expr =
|
||||
name === "group"
|
||||
? expression(row, path, ["predicate"], n, available)
|
||||
: reduction(row, name as AggregateOperator, path, n, ["predicate"], available);
|
||||
children.push(
|
||||
...(comparisons(n, (operator, value) => ({
|
||||
kind: "compare",
|
||||
expression: expr,
|
||||
operator,
|
||||
value,
|
||||
})) as QueryAggregatePredicate[]),
|
||||
);
|
||||
} else for (const entry of entries) visit(entry.value, [...path, entry.name.value]);
|
||||
};
|
||||
visit(field.value, []);
|
||||
}
|
||||
}
|
||||
return { kind: "and", children };
|
||||
};
|
||||
const predicate = (row: QueryRow, node: ValueNode, available?: Availability): QueryPredicate => {
|
||||
const children: QueryPredicate[] = [];
|
||||
const visit = (current: QueryRow, n: ValueNode, base: string[]) => {
|
||||
for (const field of pairs(n)) {
|
||||
const name = field.name.value;
|
||||
if (name === "and" || name === "or") {
|
||||
if (field.value.kind !== "ListValue")
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "Boolean predicate structure is fixed in the document", field);
|
||||
const branches = field.value.values.map((v) => {
|
||||
const before = children.length;
|
||||
visit(current, v, base);
|
||||
return { kind: "and", children: children.splice(before) } as QueryPredicate;
|
||||
});
|
||||
children.push({ kind: name, children: branches });
|
||||
} else if (name === "not") {
|
||||
const before = children.length;
|
||||
visit(current, field.value, base);
|
||||
children.push({ kind: "not", child: { kind: "and", children: children.splice(before) } });
|
||||
} else if (current.kind === "pair" && (name === "source" || name === "target"))
|
||||
visit(current[name], field.value, [...base, name]);
|
||||
else if (name === "_qx") {
|
||||
for (const meta of pairs(field.value)) {
|
||||
if (meta.name.value !== "relations") {
|
||||
const expr = expression(row, [...base, "_qx", meta.name.value], ["predicate"], meta, available);
|
||||
children.push(
|
||||
...(comparisons(meta.value, (operator, value) => ({
|
||||
kind: "compare",
|
||||
expression: expr,
|
||||
operator,
|
||||
value,
|
||||
})) as QueryPredicate[]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const edge of pairs(meta.value)) {
|
||||
if (current.kind !== "object")
|
||||
fail("QUERY_CONTRACT", "A pair is not an object with graph relationships", edge);
|
||||
const member = relationships(current).find((m) => m.displayName === edge.name.value)!;
|
||||
requireAvailable([...base, member.displayName], available, edge);
|
||||
mark(current.interfaceRevisionId, member.displayName, "predicate", edge);
|
||||
const common = {
|
||||
path: walk(row, base, ["predicate"], edge).path,
|
||||
interfaceRevisionId: current.interfaceRevisionId,
|
||||
memberId: member.id,
|
||||
targetInterfaceRevisionId: env.target(member),
|
||||
};
|
||||
for (const op of pairs(edge.value)) {
|
||||
if (op.name.value === "aggregate") {
|
||||
const config = pairs(op.value),
|
||||
where = config.find((f) => f.name.value === "where"),
|
||||
having = config.find((f) => f.name.value === "having")!;
|
||||
children.push({
|
||||
kind: "reduce",
|
||||
...common,
|
||||
where: where ? predicate(env.relational.relationRow(member), where.value) : undefined,
|
||||
having: aggregatePredicate(env.relational.relationRow(member), having.value),
|
||||
});
|
||||
} else if (op.name.value === "isNull")
|
||||
children.push({ kind: "relation", ...common, operator: "isNull", value: queryArgument(op.value) });
|
||||
else
|
||||
children.push({
|
||||
kind: "relation",
|
||||
...common,
|
||||
operator: op.name.value as "some" | "none" | "is",
|
||||
predicate: predicate(env.relational.relationRow(member), op.value),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const expr = expression(row, [...base, name], ["predicate"], field, available);
|
||||
children.push(
|
||||
...(comparisons(field.value, (operator, value) => ({
|
||||
kind: "compare",
|
||||
expression: expr,
|
||||
operator,
|
||||
value,
|
||||
})) as QueryPredicate[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(row, node, []);
|
||||
return { kind: "and", children };
|
||||
};
|
||||
|
||||
const plan = (
|
||||
owner: InterfaceRevisionId,
|
||||
member: RelationshipInterfaceMember,
|
||||
set: SelectionSetNode,
|
||||
selection: (set: SelectionSetNode, typeName: string) => QuerySelection[],
|
||||
): QueryRelationalPlan => {
|
||||
const result: QueryRelationalPlan = { stages: [], terminals: [] };
|
||||
const add = (row: QueryRow, operation: QueryRelationalPlan["stages"][number]["operation"], input?: number) => {
|
||||
if (++stageCount > 256) throw new QueryCompileError("QUERY_WORK_LIMIT", "Query exceeds 256 relational stages");
|
||||
const id = result.stages.length;
|
||||
result.stages.push({ id, row, operation, input });
|
||||
return id;
|
||||
};
|
||||
const root = env.relational.relationRow(member);
|
||||
const initial = add(root, {
|
||||
kind: "source",
|
||||
interfaceRevisionId: owner,
|
||||
memberId: member.id,
|
||||
targetInterfaceRevisionId: env.target(member),
|
||||
});
|
||||
mark(owner, member.displayName, "select", set);
|
||||
const bound = (node: FieldNode) => {
|
||||
const args = node.arguments ?? [],
|
||||
bounds = args.filter((a) => a.name.value === "first" || a.name.value === "all");
|
||||
if (bounds.length !== 1) fail("QUERY_ROW_LIMIT", "Specify exactly one first or all for groups", node);
|
||||
if (bounds[0]!.name.value === "all" && args.some((a) => a.name.value === "after"))
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all cannot continue", node);
|
||||
for (const b of bounds)
|
||||
if (
|
||||
b.value.kind !== "Variable" &&
|
||||
(b.value.kind !== "IntValue" || Number(b.value.value) < 1 || Number(b.value.value) > declaration.budgets.rows)
|
||||
)
|
||||
fail("QUERY_ROW_LIMIT", "Group page exceeds row budget", b);
|
||||
};
|
||||
const reductions = (
|
||||
row: QueryRow,
|
||||
set: SelectionSetNode,
|
||||
available: Availability,
|
||||
): QueryRelationalTerminal["reductions"] => {
|
||||
const output: QueryRelationalTerminal["reductions"] = [];
|
||||
const visit = (set: SelectionSetNode, path: string[], response: string[], op: AggregateOperator) => {
|
||||
for (const n of fields(set)) {
|
||||
const key = n.alias?.value ?? n.name.value,
|
||||
next = [...path, n.name.value],
|
||||
out = [...response, key];
|
||||
if (n.selectionSet) visit(n.selectionSet, next, out, op);
|
||||
else output.push({ path: out, reduction: reduction(row, op, next, n, [], available) });
|
||||
}
|
||||
};
|
||||
for (const n of fields(set)) {
|
||||
const op = n.name.value as AggregateOperator,
|
||||
key = n.alias?.value ?? n.name.value;
|
||||
if (op === "count") output.push({ path: [key], reduction: reduction(row, op, [], n, []) });
|
||||
else if (n.selectionSet) visit(n.selectionSet, [], [key], op);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const groupsProjection = (row: QueryRow, set: SelectionSetNode, keys: QueryKey[], available: Availability) => {
|
||||
let values: QueryRelationalTerminal["reductions"] = [];
|
||||
for (const entries of fields(set))
|
||||
if (entries.name.value === "entries" && entries.selectionSet)
|
||||
for (const n of fields(entries.selectionSet)) {
|
||||
if (n.name.value === "aggregate" && n.selectionSet)
|
||||
values.push(
|
||||
...reductions(row, n.selectionSet, available).map((v) => ({
|
||||
...v,
|
||||
path: [entries.alias?.value ?? entries.name.value, n.alias?.value ?? n.name.value, ...v.path],
|
||||
})),
|
||||
);
|
||||
if (n.name.value === "group" && n.selectionSet) {
|
||||
const visit = (set: SelectionSetNode, path: string[]) => {
|
||||
for (const f of fields(set)) {
|
||||
const next = [...path, f.name.value];
|
||||
if (f.selectionSet) visit(f.selectionSet, next);
|
||||
else if (!keys.some((k) => k.path.join(".") === next.join(".")))
|
||||
fail("QUERY_GROUP_FIELD_UNAVAILABLE", `${next.join(".")} is not a selected group key`, f);
|
||||
}
|
||||
};
|
||||
visit(n.selectionSet, []);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
const run = (row: QueryRow, stage: number, set: SelectionSetNode, path: string[], available?: Availability) => {
|
||||
for (const node of fields(set)) {
|
||||
if (!node.selectionSet) continue;
|
||||
if (node.directives?.length)
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "Relational stages cannot be conditionally selected", node);
|
||||
const name = node.name.value,
|
||||
outputPath = [...path, node.alias?.value ?? name];
|
||||
const args = new Map((node.arguments ?? []).map((a) => [a.name.value, a.value]));
|
||||
if (name === "filter")
|
||||
run(
|
||||
row,
|
||||
add(row, { kind: "filter", predicate: predicate(row, args.get("where")!, available) }, stage),
|
||||
node.selectionSet,
|
||||
outputPath,
|
||||
available,
|
||||
);
|
||||
else if (name === "distinct") {
|
||||
const keys = keyList(row, args.get("by")!, "distinct", available);
|
||||
run(row, add(row, { kind: "distinct", keys }, stage), node.selectionSet, outputPath, keys);
|
||||
} else if (name === "expand") {
|
||||
const expand = (current: QueryRow, set: SelectionSetNode, memberPath: string[], output: string[]) => {
|
||||
for (const n of fields(set)) {
|
||||
if (!n.selectionSet) continue;
|
||||
const member = relationships(current).find((m) => m.displayName === n.name.value);
|
||||
const next = [...memberPath, n.name.value],
|
||||
out = [...output, n.alias?.value ?? n.name.value];
|
||||
if (member && (member.cardinality === "many" || member.cardinality === "many-unique")) {
|
||||
requireAvailable(next, available, n);
|
||||
if (current.kind !== "object") fail("QUERY_CONTRACT", "Expected object expansion source", n);
|
||||
const walked = walk(row, memberPath, ["select"], n);
|
||||
mark(current.interfaceRevisionId, member.displayName, "select", n);
|
||||
const target = env.relational.relationRow(member),
|
||||
pair: QueryRow = { kind: "pair", source: row, target };
|
||||
const newAvailable = available
|
||||
? [
|
||||
...available.map((k) => ({
|
||||
...k,
|
||||
path: ["source", ...k.path],
|
||||
expression: { ...k.expression, path: [{ kind: "source" as const }, ...k.expression.path] },
|
||||
})),
|
||||
{
|
||||
path: ["target", "_qx", "entry"],
|
||||
expression: {
|
||||
path: [{ kind: "target" as const }],
|
||||
leaf: { kind: "entry" as const },
|
||||
type: valueType.string,
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
run(
|
||||
pair,
|
||||
add(
|
||||
pair,
|
||||
{
|
||||
kind: "expand",
|
||||
path: walked.path,
|
||||
interfaceRevisionId: current.interfaceRevisionId,
|
||||
memberId: member.id,
|
||||
targetInterfaceRevisionId: env.target(member),
|
||||
},
|
||||
stage,
|
||||
),
|
||||
n.selectionSet,
|
||||
out,
|
||||
newAvailable,
|
||||
);
|
||||
} else {
|
||||
const f = rowFields(current).get(n.name.value);
|
||||
if (f?.kind === "row") expand(f.row, n.selectionSet, next, out);
|
||||
else fail("QUERY_EXPANSION_REQUIRED", "Expected a declared expansion path", n);
|
||||
}
|
||||
}
|
||||
};
|
||||
expand(row, node.selectionSet, [], outputPath);
|
||||
} else if (name === "aggregate" || name === "groups") {
|
||||
if (name === "groups") bound(node);
|
||||
const keys = name === "groups" ? keyList(row, args.get("by")!, "group", available) : [];
|
||||
const terminal: QueryRelationalTerminal = {
|
||||
stage,
|
||||
path: outputPath,
|
||||
kind: name,
|
||||
keys,
|
||||
reductions:
|
||||
name === "aggregate"
|
||||
? reductions(row, node.selectionSet, available)
|
||||
: groupsProjection(row, node.selectionSet, keys, available),
|
||||
order: [],
|
||||
residual: false,
|
||||
where: args.has("where") ? predicate(row, args.get("where")!, available) : undefined,
|
||||
having: args.has("having") ? aggregatePredicate(row, args.get("having")!, keys, available) : undefined,
|
||||
first: args.has("first") ? queryArgument(args.get("first")!) : undefined,
|
||||
all: args.has("all") ? queryArgument(args.get("all")!) : undefined,
|
||||
after: args.has("after") ? queryArgument(args.get("after")!) : undefined,
|
||||
selection: selection(
|
||||
node.selectionSet,
|
||||
name === "aggregate"
|
||||
? env.relational.rowset(row).getFields().aggregate!.type.toString().replace(/!$/u, "")
|
||||
: env.relational.rowset(row).getFields().groups!.type.toString().replace(/!$/u, ""),
|
||||
),
|
||||
};
|
||||
const order = args.get("orderBy");
|
||||
if (order) {
|
||||
if (order.kind !== "ListValue")
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", "Ordering is a fixed list of field directions", order);
|
||||
for (const item of order.values) {
|
||||
const start = terminal.order.length;
|
||||
const visit = (n: ValueNode, path: string[]) => {
|
||||
if (n.kind === "EnumValue") {
|
||||
const [op, ...operand] = path;
|
||||
let expr: QueryExpression | QueryReduction;
|
||||
if (op === "group") {
|
||||
if (!keys.some((k) => k.path.join(".") === operand.join(".")))
|
||||
fail("QUERY_GROUP_FIELD_UNAVAILABLE", "Sort key was not grouped", n);
|
||||
expr = expression(row, operand, ["order"], n, available);
|
||||
} else expr = reduction(row, op as AggregateOperator, operand, n, ["order"], available);
|
||||
terminal.order.push({ expression: expr, descending: n.value === "DESC" });
|
||||
} else for (const f of pairs(n)) visit(f.value, [...path, f.name.value]);
|
||||
};
|
||||
visit(item, []);
|
||||
if (terminal.order.length !== start + 1)
|
||||
fail("QUERY_ORDER_INVALID", "Each orderBy element must name exactly one field", item);
|
||||
}
|
||||
}
|
||||
result.terminals.push(terminal);
|
||||
} else fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported relational stage ${name}`, node);
|
||||
}
|
||||
};
|
||||
run(root, initial, set, []);
|
||||
for (const terminal of result.terminals) {
|
||||
let stage: QueryRelationalPlan["stages"][number] | undefined = result.stages[terminal.stage];
|
||||
let rpc = needsRpc(terminal);
|
||||
while (stage) {
|
||||
rpc ||= needsRpc(stage.operation);
|
||||
stage = stage.input === undefined ? undefined : result.stages[stage.input];
|
||||
}
|
||||
terminal.residual = rpc;
|
||||
if (rpc && terminal.after)
|
||||
fail(
|
||||
"QUERY_UNSUPPORTED_FEATURE",
|
||||
"RPC relational inputs require a bounded first/all window without continuation",
|
||||
set,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return { expression, predicate, aggregatePredicate, keyList, plan, needsRpc };
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import * as wire from "../gen/camino/schema_pb.js";
|
||||
import type { QueryArgument, QuerySelection } from "./types.js";
|
||||
import type {
|
||||
QueryAggregatePredicate,
|
||||
QueryExpression,
|
||||
QueryPathStep,
|
||||
QueryPredicate,
|
||||
QueryReduction,
|
||||
QueryRelationalPlan,
|
||||
QueryRow,
|
||||
} from "./relational.js";
|
||||
|
||||
export function relationalWire(
|
||||
argument: (v: QueryArgument) => wire.QueryArgument,
|
||||
selection: (v: QuerySelection) => wire.QuerySelection,
|
||||
) {
|
||||
const path = (p: QueryPathStep): wire.QueryPathStep =>
|
||||
create(wire.QueryPathStepSchema, {
|
||||
step: p.kind === "relation" ? { case: "relation", value: p } : { case: p.kind, value: true },
|
||||
});
|
||||
const expression = (e: QueryExpression) =>
|
||||
create(wire.QueryExpressionSchema, {
|
||||
path: e.path.map(path),
|
||||
valueTypeJson: JSON.stringify(e.type),
|
||||
leaf:
|
||||
e.leaf.kind === "field"
|
||||
? { case: "field", value: e.leaf }
|
||||
: e.leaf.kind === "ref"
|
||||
? { case: "ref", value: e.leaf.interfaceRevisionId }
|
||||
: { case: e.leaf.kind, value: true },
|
||||
});
|
||||
const reduction = (r: QueryReduction) =>
|
||||
create(wire.QueryReductionSchema, {
|
||||
operator: {
|
||||
count: wire.QueryAggregateOperator.COUNT,
|
||||
countPresent: wire.QueryAggregateOperator.COUNT_PRESENT,
|
||||
sum: wire.QueryAggregateOperator.SUM,
|
||||
avg: wire.QueryAggregateOperator.AVG,
|
||||
min: wire.QueryAggregateOperator.MIN,
|
||||
max: wire.QueryAggregateOperator.MAX,
|
||||
}[r.operator],
|
||||
operand: r.operand && expression(r.operand),
|
||||
valueTypeJson: JSON.stringify(r.type),
|
||||
});
|
||||
const operand = (v: QueryExpression | QueryReduction) =>
|
||||
create(wire.QueryOperandSchema, {
|
||||
operand:
|
||||
"operator" in v ? { case: "reduction", value: reduction(v) } : { case: "expression", value: expression(v) },
|
||||
});
|
||||
const predicate = (p: QueryPredicate | QueryAggregatePredicate): wire.QueryPredicate => {
|
||||
switch (p.kind) {
|
||||
case "and":
|
||||
case "or":
|
||||
return create(wire.QueryPredicateSchema, {
|
||||
predicate: { case: p.kind, value: { children: p.children.map(predicate) } },
|
||||
});
|
||||
case "not":
|
||||
return create(wire.QueryPredicateSchema, { predicate: { case: "not", value: predicate(p.child) } });
|
||||
case "compare":
|
||||
return create(wire.QueryPredicateSchema, {
|
||||
predicate: {
|
||||
case: "compare",
|
||||
value: {
|
||||
operand: operand(p.expression),
|
||||
value: argument(p.value),
|
||||
operator: {
|
||||
eq: wire.QueryComparisonOperator.EQ,
|
||||
in: wire.QueryComparisonOperator.IN,
|
||||
isNull: wire.QueryComparisonOperator.IS_NULL,
|
||||
lt: wire.QueryComparisonOperator.LT,
|
||||
lte: wire.QueryComparisonOperator.LTE,
|
||||
gt: wire.QueryComparisonOperator.GT,
|
||||
gte: wire.QueryComparisonOperator.GTE,
|
||||
}[p.operator],
|
||||
},
|
||||
},
|
||||
});
|
||||
case "relation":
|
||||
return create(wire.QueryPredicateSchema, {
|
||||
predicate: {
|
||||
case: "relation",
|
||||
value: {
|
||||
path: p.path.map(path),
|
||||
relation: p,
|
||||
operator: {
|
||||
some: wire.QueryRelationPredicateOperator.SOME,
|
||||
none: wire.QueryRelationPredicateOperator.NONE,
|
||||
is: wire.QueryRelationPredicateOperator.IS,
|
||||
isNull: wire.QueryRelationPredicateOperator.IS_NULL,
|
||||
}[p.operator],
|
||||
predicate: p.predicate && predicate(p.predicate),
|
||||
value: p.value && argument(p.value),
|
||||
},
|
||||
},
|
||||
});
|
||||
case "reduce":
|
||||
return create(wire.QueryPredicateSchema, {
|
||||
predicate: {
|
||||
case: "reduce",
|
||||
value: {
|
||||
path: p.path.map(path),
|
||||
relation: p,
|
||||
where: p.where && predicate(p.where),
|
||||
having: predicate(p.having),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
const row = (r: QueryRow): wire.QueryRow =>
|
||||
create(wire.QueryRowSchema, {
|
||||
row:
|
||||
r.kind === "object"
|
||||
? {
|
||||
case: "object",
|
||||
value: {
|
||||
interfaceRevisionId: r.interfaceRevisionId,
|
||||
membership: !!r.membership,
|
||||
keyType: r.membership?.keyType,
|
||||
},
|
||||
}
|
||||
: { case: "pair", value: { source: row(r.source), target: row(r.target) } },
|
||||
});
|
||||
const plan = (p: QueryRelationalPlan) =>
|
||||
create(wire.QueryRelationalPlanSchema, {
|
||||
stages: p.stages.map((s) => {
|
||||
const op = s.operation;
|
||||
const operation: wire.QueryRelationalStage["operation"] =
|
||||
op.kind === "source"
|
||||
? { case: "source", value: create(wire.QueryRelationPathSchema, op) }
|
||||
: op.kind === "filter"
|
||||
? { case: "filter", value: predicate(op.predicate) }
|
||||
: op.kind === "expand"
|
||||
? {
|
||||
case: "expand",
|
||||
value: create(wire.QueryExpansionSchema, { path: op.path.map(path), relation: op }),
|
||||
}
|
||||
: {
|
||||
case: "distinct",
|
||||
value: create(wire.QueryDistinctSchema, {
|
||||
keys: op.keys.map((k) => ({ ...k, expression: expression(k.expression) })),
|
||||
}),
|
||||
};
|
||||
return create(wire.QueryRelationalStageSchema, { id: s.id, input: s.input, row: row(s.row), operation });
|
||||
}),
|
||||
terminals: p.terminals.map((t) => ({
|
||||
stage: t.stage,
|
||||
path: t.path,
|
||||
groups: t.kind === "groups",
|
||||
keys: t.keys.map((k) => ({ ...k, expression: expression(k.expression) })),
|
||||
reductions: t.reductions.map((r) => ({ ...r, reduction: reduction(r.reduction) })),
|
||||
where: t.where && predicate(t.where),
|
||||
having: t.having && predicate(t.having),
|
||||
order: t.order.map((o) => ({ operand: operand(o.expression), descending: o.descending })),
|
||||
first: t.first && argument(t.first),
|
||||
all: t.all && argument(t.all),
|
||||
after: t.after && argument(t.after),
|
||||
selection: t.selection.map(selection),
|
||||
residual: t.residual,
|
||||
})),
|
||||
});
|
||||
return { predicate, plan };
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import {
|
||||
GraphQLBoolean,
|
||||
GraphQLString,
|
||||
GraphQLInt,
|
||||
GraphQLScalarType,
|
||||
GraphQLObjectType,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
type GraphQLInputType,
|
||||
type GraphQLOutputType,
|
||||
type GraphQLInputFieldConfigMap,
|
||||
type GraphQLFieldConfigMap,
|
||||
type GraphQLEnumType,
|
||||
type DocumentNode,
|
||||
type SelectionSetNode,
|
||||
type FragmentDefinitionNode,
|
||||
} from "graphql";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
valueType,
|
||||
type ValueType,
|
||||
type InterfaceRevision,
|
||||
type InterfaceRevisionId,
|
||||
type RelationshipInterfaceMember,
|
||||
} from "../capability-model/types.js";
|
||||
import {
|
||||
aggregateType,
|
||||
aggregateOperators,
|
||||
queryKeyType,
|
||||
type QueryRow,
|
||||
type AggregateOperator,
|
||||
} from "./relational.js";
|
||||
import { QueryCompileError } from "./types.js";
|
||||
|
||||
export const rowKey = (row: QueryRow): string => JSON.stringify(row);
|
||||
export const objectRow = (
|
||||
interfaceRevisionId: InterfaceRevisionId,
|
||||
membership?: { keyType?: "string" | "boolean" | "int64" },
|
||||
): QueryRow => ({ kind: "object", interfaceRevisionId, ...(membership ? { membership } : {}) });
|
||||
export type SchemaRowField = { kind: "leaf"; type: ValueType } | { kind: "row"; row: QueryRow; optional: boolean };
|
||||
export interface RelationalSchemaEnvironment {
|
||||
contracts: Map<InterfaceRevisionId, InterfaceRevision>;
|
||||
target(member: RelationshipInterfaceMember): InterfaceRevisionId;
|
||||
name(id: InterfaceRevisionId): string;
|
||||
scalar(type: ValueType): GraphQLInputType & GraphQLOutputType;
|
||||
comparison(type: ValueType): GraphQLInputObjectType;
|
||||
scalarTypes: Map<string, ValueType>;
|
||||
direction: GraphQLEnumType;
|
||||
cursor: GraphQLScalarType;
|
||||
pageInfo: GraphQLObjectType;
|
||||
}
|
||||
|
||||
export function relationalSchema(env: RelationalSchemaEnvironment, document?: DocumentNode) {
|
||||
const outputCache = new Map<string, GraphQLObjectType>();
|
||||
const inputCache = new Map<string, GraphQLInputObjectType>();
|
||||
const referenceCache = new Map<string, GraphQLScalarType>();
|
||||
const rowsets = new Map<string, QueryRow>();
|
||||
const expansionPaths = new Map<string, Map<string, string[]>>();
|
||||
const roles = new Map<
|
||||
string,
|
||||
{ kind: "rowset" | "aggregate" | "groups" | "keys" | "expand" | "relations"; row: QueryRow }
|
||||
>();
|
||||
const many = (member: RelationshipInterfaceMember) =>
|
||||
member.cardinality === "many" || member.cardinality === "many-unique";
|
||||
const relationRow = (member: RelationshipInterfaceMember) =>
|
||||
objectRow(env.target(member), { keyType: member.keyType });
|
||||
const relations = (row: QueryRow) =>
|
||||
row.kind === "object"
|
||||
? env.contracts
|
||||
.get(row.interfaceRevisionId)!
|
||||
.members.filter(
|
||||
(member): member is RelationshipInterfaceMember => member.kind === "relationship" && !!member.queryRead,
|
||||
)
|
||||
: [];
|
||||
const fields = (row: QueryRow): Map<string, SchemaRowField> => {
|
||||
if (row.kind === "pair")
|
||||
return new Map([
|
||||
["source", { kind: "row", row: row.source, optional: false }],
|
||||
["target", { kind: "row", row: row.target, optional: false }],
|
||||
]);
|
||||
return new Map(
|
||||
env.contracts.get(row.interfaceRevisionId)!.members.flatMap((member): [string, SchemaRowField][] => {
|
||||
if (member.kind === "operation" || !member.queryRead) return [];
|
||||
if (member.kind === "value") return [[member.displayName, { kind: "leaf", type: member.valueType }]];
|
||||
if (many(member)) return [];
|
||||
return [
|
||||
[
|
||||
member.displayName,
|
||||
{ kind: "row", row: objectRow(env.target(member)), optional: member.cardinality === "optional-one" },
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
};
|
||||
const refs = (id: InterfaceRevisionId) => {
|
||||
let result = referenceCache.get(id);
|
||||
if (!result) {
|
||||
result = new GraphQLScalarType({
|
||||
name: `QxRef_${env.name(id)}`,
|
||||
parseValue: (value) => value,
|
||||
parseLiteral() {
|
||||
throw new Error("Managed references must be supplied as typed variables");
|
||||
},
|
||||
});
|
||||
referenceCache.set(id, result);
|
||||
env.scalarTypes.set(result.name, valueType.interfaceRef(id));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const scalar = (type: ValueType): GraphQLInputType & GraphQLOutputType => {
|
||||
if (type.kind === "optional") return scalar(type.value);
|
||||
return type.kind === "object-ref" && type.expectation.kind === "interface"
|
||||
? refs(type.expectation.interfaceRevisionId)
|
||||
: env.scalar(type);
|
||||
};
|
||||
const leafFields = (row: QueryRow) => {
|
||||
const result = new Map<string, ValueType>();
|
||||
if (row.kind === "object") result.set("ref", valueType.interfaceRef(row.interfaceRevisionId));
|
||||
if (row.kind === "pair" || row.membership) result.set("entry", valueType.string);
|
||||
if (row.kind === "object" && row.membership?.keyType)
|
||||
result.set(
|
||||
"mapKey",
|
||||
row.membership.keyType === "boolean"
|
||||
? valueType.bool
|
||||
: row.membership.keyType === "int64"
|
||||
? valueType.int64
|
||||
: valueType.string,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
const typeName = (role: string, row: QueryRow, suffix = "") =>
|
||||
`Qx${role}_${createHash("sha256")
|
||||
.update(rowKey(row) + suffix)
|
||||
.digest("hex")
|
||||
.slice(0, 16)}`;
|
||||
const out = (role: string, row: QueryRow, make: () => GraphQLFieldConfigMap<unknown, unknown>, suffix = "") => {
|
||||
const key = typeName(role, row, suffix);
|
||||
let result = outputCache.get(key);
|
||||
if (!result) {
|
||||
result = new GraphQLObjectType({ name: key, fields: make });
|
||||
outputCache.set(key, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const inp = (role: string, row: QueryRow, make: () => GraphQLInputFieldConfigMap, suffix = "") => {
|
||||
const key = typeName(role, row, suffix);
|
||||
let result = inputCache.get(key);
|
||||
if (!result) {
|
||||
result = new GraphQLInputObjectType({ name: key, fields: make });
|
||||
inputCache.set(key, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const compare = (type: ValueType) => {
|
||||
const base = type.kind === "optional" ? type.value : type;
|
||||
if (base.kind !== "object-ref") return env.comparison(type);
|
||||
return inp(
|
||||
"RefCompare",
|
||||
objectRow(
|
||||
base.expectation.kind === "interface"
|
||||
? base.expectation.interfaceRevisionId
|
||||
: (() => {
|
||||
throw new Error("Expected interface reference");
|
||||
})(),
|
||||
),
|
||||
() => ({ eq: { type: scalar(type) }, in: { type: new GraphQLList(new GraphQLNonNull(scalar(type))) } }),
|
||||
);
|
||||
};
|
||||
// Recursive interface graphs are interned. Synthetic pair graphs are finite and document-driven.
|
||||
const columns = (
|
||||
row: QueryRow,
|
||||
operator?: Exclude<AggregateOperator, "count">,
|
||||
nullable = false,
|
||||
): GraphQLObjectType =>
|
||||
out(
|
||||
"Columns",
|
||||
row,
|
||||
() => {
|
||||
const result: GraphQLFieldConfigMap<unknown, unknown> = {};
|
||||
for (const [name, field] of fields(row)) {
|
||||
if (field.kind === "row")
|
||||
result[name] = { type: new GraphQLNonNull(columns(field.row, operator, nullable || field.optional)) };
|
||||
else {
|
||||
let type = field.type;
|
||||
if (operator) {
|
||||
try {
|
||||
type = aggregateType(operator, type);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else if (!queryKeyType(type)) continue;
|
||||
result[name] = {
|
||||
type:
|
||||
type.kind === "optional" || (!operator && nullable) ? scalar(type) : new GraphQLNonNull(scalar(type)),
|
||||
};
|
||||
}
|
||||
}
|
||||
const metadata: GraphQLFieldConfigMap<unknown, unknown> = {};
|
||||
for (const [name, type] of leafFields(row)) {
|
||||
if (operator && operator !== "countPresent") continue;
|
||||
const t = scalar(operator ? aggregateType(operator, type) : type);
|
||||
metadata[name] = { type: !operator && nullable ? t : new GraphQLNonNull(t) };
|
||||
}
|
||||
if (Object.keys(metadata).length)
|
||||
result._qx = { type: new GraphQLNonNull(out("ColumnsMeta", row, () => metadata, `${operator}:${nullable}`)) };
|
||||
// A type may have no matching scalar at this node; a private schema sentinel
|
||||
// keeps it valid while compiler validation forbids selecting that sentinel.
|
||||
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
|
||||
return result;
|
||||
},
|
||||
`${operator}:${nullable}`,
|
||||
);
|
||||
const aggregate = (row: QueryRow): GraphQLObjectType => {
|
||||
const result = out("Aggregate", row, () =>
|
||||
Object.fromEntries(
|
||||
aggregateOperators.map((op) => [
|
||||
op,
|
||||
{ type: new GraphQLNonNull(op === "count" ? env.scalar(valueType.uint64) : columns(row, op)) },
|
||||
]),
|
||||
),
|
||||
);
|
||||
roles.set(result.name, { kind: "aggregate", row });
|
||||
return result;
|
||||
};
|
||||
const keys = (row: QueryRow): GraphQLInputObjectType =>
|
||||
inp("Keys", row, () => {
|
||||
const result: GraphQLInputFieldConfigMap = {};
|
||||
for (const [name, field] of fields(row)) {
|
||||
if (field.kind === "row") result[name] = { type: keys(field.row) };
|
||||
else if (queryKeyType(field.type)) result[name] = { type: GraphQLBoolean };
|
||||
}
|
||||
const meta = Object.fromEntries([...leafFields(row)].map(([name]) => [name, { type: GraphQLBoolean }]));
|
||||
if (Object.keys(meta).length) result._qx = { type: inp("KeyMeta", row, () => meta) };
|
||||
return result;
|
||||
});
|
||||
const comparisons = (
|
||||
row: QueryRow,
|
||||
operator?: Exclude<AggregateOperator, "count">,
|
||||
ordering = false,
|
||||
): GraphQLInputObjectType =>
|
||||
inp(
|
||||
"Expressions",
|
||||
row,
|
||||
() => {
|
||||
const result: GraphQLInputFieldConfigMap = {};
|
||||
for (const [name, field] of fields(row)) {
|
||||
if (field.kind === "row") result[name] = { type: comparisons(field.row, operator, ordering) };
|
||||
else {
|
||||
let type = field.type;
|
||||
if (operator) {
|
||||
try {
|
||||
type = aggregateType(operator, type);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result[name] = { type: ordering ? env.direction : compare(type) };
|
||||
}
|
||||
}
|
||||
const meta: GraphQLInputFieldConfigMap = {};
|
||||
for (const [name, type] of leafFields(row))
|
||||
if (!operator || operator === "countPresent")
|
||||
meta[name] = { type: ordering ? env.direction : compare(operator ? aggregateType(operator, type) : type) };
|
||||
if (Object.keys(meta).length)
|
||||
result._qx = { type: inp("ExpressionMeta", row, () => meta, `${operator}:${ordering}`) };
|
||||
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
|
||||
return result;
|
||||
},
|
||||
`${operator}:${ordering}`,
|
||||
);
|
||||
const having = (row: QueryRow, ordering = false): GraphQLInputObjectType =>
|
||||
inp(
|
||||
"Having",
|
||||
row,
|
||||
() => {
|
||||
const result: GraphQLInputFieldConfigMap = { group: { type: comparisons(row, undefined, ordering) } };
|
||||
for (const op of aggregateOperators)
|
||||
result[op] = {
|
||||
type:
|
||||
op === "count" ? (ordering ? env.direction : compare(valueType.uint64)) : comparisons(row, op, ordering),
|
||||
};
|
||||
if (!ordering)
|
||||
Object.assign(result, {
|
||||
and: { type: new GraphQLList(new GraphQLNonNull(having(row))) },
|
||||
or: { type: new GraphQLList(new GraphQLNonNull(having(row))) },
|
||||
not: { type: having(row) },
|
||||
});
|
||||
return result;
|
||||
},
|
||||
String(ordering),
|
||||
);
|
||||
const where = (row: QueryRow): GraphQLInputObjectType =>
|
||||
inp("Where", row, () => {
|
||||
const result: GraphQLInputFieldConfigMap = {
|
||||
and: { type: new GraphQLList(new GraphQLNonNull(where(row))) },
|
||||
or: { type: new GraphQLList(new GraphQLNonNull(where(row))) },
|
||||
not: { type: where(row) },
|
||||
};
|
||||
for (const [name, field] of fields(row)) {
|
||||
if (row.kind === "object" && field.kind === "row") continue;
|
||||
if (name in result) throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${name}`);
|
||||
result[name] = { type: field.kind === "row" ? where(field.row) : compare(field.type) };
|
||||
}
|
||||
result._qx = {
|
||||
type: inp("WhereMeta", row, () => {
|
||||
const meta: GraphQLInputFieldConfigMap = {};
|
||||
for (const [name, type] of leafFields(row)) meta[name] = { type: compare(type) };
|
||||
const edges = relations(row);
|
||||
if (edges.length)
|
||||
meta.relations = {
|
||||
type: inp("WhereRelations", row, () =>
|
||||
Object.fromEntries(
|
||||
edges.map((member) => {
|
||||
const target = relationRow(member);
|
||||
return [
|
||||
member.displayName,
|
||||
{
|
||||
type: inp(
|
||||
"RelationPredicate",
|
||||
target,
|
||||
(): GraphQLInputFieldConfigMap =>
|
||||
many(member)
|
||||
? {
|
||||
some: { type: where(target) },
|
||||
none: { type: where(target) },
|
||||
aggregate: {
|
||||
type: inp("ReductionPredicate", target, () => ({
|
||||
where: { type: where(target) },
|
||||
having: { type: new GraphQLNonNull(having(target)) },
|
||||
})),
|
||||
},
|
||||
}
|
||||
: { is: { type: where(target) }, isNull: { type: GraphQLBoolean } },
|
||||
rowKey(row) + member.id,
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
return meta;
|
||||
}),
|
||||
};
|
||||
return result;
|
||||
});
|
||||
|
||||
const fragments = new Map(
|
||||
(document?.definitions ?? [])
|
||||
.filter((d): d is FragmentDefinitionNode => d.kind === "FragmentDefinition")
|
||||
.map((d) => [d.name.value, d]),
|
||||
);
|
||||
let visited = 0;
|
||||
const selections = (set: SelectionSetNode, active: ReadonlySet<string> = new Set()): import("graphql").FieldNode[] =>
|
||||
set.selections.flatMap((node) => {
|
||||
if (++visited > 10000)
|
||||
throw new QueryCompileError("QUERY_WORK_LIMIT", "Query schema discovery exceeds 10000 nodes");
|
||||
if (node.kind === "Field") return [node];
|
||||
if (node.kind === "FragmentSpread") {
|
||||
if (active.has(node.name.value) || active.size >= 128)
|
||||
throw new QueryCompileError("QUERY_VALIDATION", "Cyclic or excessively nested fragments");
|
||||
const fragment = fragments.get(node.name.value);
|
||||
return fragment ? selections(fragment.selectionSet, new Set([...active, node.name.value])) : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const discoverExpansion = (root: QueryRow, current: QueryRow, set: SelectionSetNode, path: string[] = []) => {
|
||||
for (const node of selections(set)) {
|
||||
if (!node.selectionSet) continue;
|
||||
const member = relations(current).find((m) => m.displayName === node.name.value);
|
||||
const next = [...path, node.name.value];
|
||||
if (member && many(member)) {
|
||||
const map = expansionPaths.get(rowKey(root)) ?? new Map();
|
||||
map.set(next.join("."), next);
|
||||
expansionPaths.set(rowKey(root), map);
|
||||
discoverRows({ kind: "pair", source: root, target: relationRow(member) }, node.selectionSet);
|
||||
} else {
|
||||
const field = fields(current).get(node.name.value);
|
||||
if (field?.kind === "row") discoverExpansion(root, field.row, node.selectionSet, next);
|
||||
}
|
||||
}
|
||||
};
|
||||
const discoverRows = (row: QueryRow, set: SelectionSetNode) => {
|
||||
rowsets.set(rowKey(row), row);
|
||||
if (rowsets.size > 256) throw new QueryCompileError("QUERY_WORK_LIMIT", "Query exceeds 256 row shapes");
|
||||
for (const node of selections(set))
|
||||
if (node.selectionSet) {
|
||||
if (node.name.value === "expand") discoverExpansion(row, row, node.selectionSet);
|
||||
else if (node.name.value === "filter" || node.name.value === "distinct") discoverRows(row, node.selectionSet);
|
||||
}
|
||||
};
|
||||
const discoverObject = (row: QueryRow, set: SelectionSetNode) => {
|
||||
for (const node of selections(set))
|
||||
if (node.selectionSet) {
|
||||
if (node.name.value === "_qx") {
|
||||
for (const meta of selections(node.selectionSet))
|
||||
if (meta.name.value === "relations" && meta.selectionSet)
|
||||
for (const edge of selections(meta.selectionSet)) {
|
||||
const member = relations(row).find((m) => m.displayName === edge.name.value);
|
||||
if (member && many(member) && edge.selectionSet) discoverRows(relationRow(member), edge.selectionSet);
|
||||
}
|
||||
} else {
|
||||
const member = relations(row).find((m) => m.displayName === node.name.value);
|
||||
if (member) {
|
||||
if (!many(member)) discoverObject(objectRow(env.target(member)), node.selectionSet);
|
||||
else
|
||||
for (const entries of selections(node.selectionSet))
|
||||
if (entries.name.value === "entries" && entries.selectionSet)
|
||||
for (const child of selections(entries.selectionSet))
|
||||
if (child.name.value === "node" && child.selectionSet)
|
||||
discoverObject(objectRow(env.target(member)), child.selectionSet);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const discover = (root: InterfaceRevisionId) => {
|
||||
for (const operation of document?.definitions ?? [])
|
||||
if (operation.kind === "OperationDefinition")
|
||||
for (const node of selections(operation.selectionSet))
|
||||
if (node.name.value === "root" && node.selectionSet) discoverObject(objectRow(root), node.selectionSet);
|
||||
};
|
||||
const expand = (root: QueryRow, current: QueryRow, path: string[] = []): GraphQLObjectType =>
|
||||
out(
|
||||
"Expand",
|
||||
current,
|
||||
() => {
|
||||
const result: GraphQLFieldConfigMap<unknown, unknown> = {};
|
||||
const paths = [...(expansionPaths.get(rowKey(root))?.values() ?? [])].filter((p) =>
|
||||
path.every((part, i) => p[i] === part),
|
||||
);
|
||||
for (const name of new Set(paths.map((p) => p[path.length]).filter((v): v is string => !!v))) {
|
||||
const member = relations(current).find((m) => m.displayName === name);
|
||||
if (member && many(member))
|
||||
result[name] = {
|
||||
type: new GraphQLNonNull(rowset({ kind: "pair", source: root, target: relationRow(member) })),
|
||||
};
|
||||
else {
|
||||
const field = fields(current).get(name);
|
||||
if (field?.kind === "row")
|
||||
result[name] = { type: new GraphQLNonNull(expand(root, field.row, [...path, name])) };
|
||||
}
|
||||
}
|
||||
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
|
||||
return result;
|
||||
},
|
||||
rowKey(root) + JSON.stringify(path),
|
||||
);
|
||||
const rowset = (row: QueryRow): GraphQLObjectType => {
|
||||
const result = out("Rows", row, () => {
|
||||
const entry = out("GroupEntry", row, () => ({
|
||||
key: { type: new GraphQLNonNull(GraphQLString) },
|
||||
cursor: { type: env.cursor },
|
||||
group: { type: new GraphQLNonNull(columns(row)) },
|
||||
aggregate: { type: new GraphQLNonNull(aggregate(row)) },
|
||||
}));
|
||||
const connection = out("Groups", row, () => ({
|
||||
entries: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(entry))) },
|
||||
pageInfo: { type: new GraphQLNonNull(env.pageInfo) },
|
||||
}));
|
||||
roles.set(connection.name, { kind: "groups", row });
|
||||
return {
|
||||
filter: { type: new GraphQLNonNull(rowset(row)), args: { where: { type: new GraphQLNonNull(where(row)) } } },
|
||||
distinct: { type: new GraphQLNonNull(rowset(row)), args: { by: { type: new GraphQLNonNull(keys(row)) } } },
|
||||
expand: { type: new GraphQLNonNull(expand(row, row)) },
|
||||
aggregate: { type: new GraphQLNonNull(aggregate(row)), args: { where: { type: where(row) } } },
|
||||
groups: {
|
||||
type: new GraphQLNonNull(connection),
|
||||
args: {
|
||||
by: { type: new GraphQLNonNull(keys(row)) },
|
||||
where: { type: where(row) },
|
||||
having: { type: having(row) },
|
||||
orderBy: { type: new GraphQLList(new GraphQLNonNull(having(row, true))) },
|
||||
first: { type: GraphQLInt },
|
||||
all: { type: GraphQLInt },
|
||||
after: { type: env.cursor },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
roles.set(result.name, { kind: "rowset", row });
|
||||
return result;
|
||||
};
|
||||
const helpers = (id: InterfaceRevisionId): GraphQLObjectType | undefined => {
|
||||
const row = objectRow(id),
|
||||
edges = relations(row).filter(many);
|
||||
if (!edges.length) return undefined;
|
||||
const result = out("Relations", row, () =>
|
||||
Object.fromEntries(
|
||||
edges.map((member) => [member.displayName, { type: new GraphQLNonNull(rowset(relationRow(member))) }]),
|
||||
),
|
||||
);
|
||||
roles.set(result.name, { kind: "relations", row });
|
||||
return result;
|
||||
};
|
||||
return { refs, fields, relations, relationRow, where, rowset, helpers, roles, discover, scalar, compare };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { InterfaceRevisionId, MemberId, ValueType } from "../capability-model/types.js";
|
||||
import { valueType } from "../capability-model/types.js";
|
||||
import { QueryCompileError, type QueryArgument, type QueryUse } from "./types.js";
|
||||
|
||||
/** A row is an input membership, not a newly allocated Camino object. */
|
||||
export type QueryRow =
|
||||
| {
|
||||
kind: "object";
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
membership?: { keyType?: "string" | "boolean" | "int64" };
|
||||
}
|
||||
| { kind: "pair"; source: QueryRow; target: QueryRow };
|
||||
|
||||
export type AggregateOperator = "count" | "countPresent" | "sum" | "avg" | "min" | "max";
|
||||
export const aggregateOperators: readonly AggregateOperator[] = ["count", "countPresent", "sum", "avg", "min", "max"];
|
||||
|
||||
export function aggregateType(operator: AggregateOperator, input?: ValueType): ValueType {
|
||||
if (operator === "count") return valueType.uint64;
|
||||
const base = input?.kind === "optional" ? input.value : input;
|
||||
if (operator === "countPresent" && (base?.kind === "scalar" || base?.kind === "object-ref")) return valueType.uint64;
|
||||
if (base?.kind !== "scalar")
|
||||
throw new QueryCompileError("QUERY_AGGREGATE_TYPE", `${operator} requires a supported scalar operand`);
|
||||
const numeric = ["int32", "uint32", "int64", "uint64", "double"].includes(base.name);
|
||||
if ((operator === "min" || operator === "max") && (numeric || base.name === "string"))
|
||||
return valueType.optional(base);
|
||||
if (numeric && operator === "avg") return valueType.optional(valueType.double);
|
||||
if (numeric && operator === "sum")
|
||||
return valueType.optional(
|
||||
base.name === "double" ? valueType.double : base.name.startsWith("u") ? valueType.uint64 : valueType.int64,
|
||||
);
|
||||
throw new QueryCompileError("QUERY_AGGREGATE_TYPE", `${operator} does not accept ${base.name}`);
|
||||
}
|
||||
|
||||
export function queryKeyType(type: ValueType): boolean {
|
||||
if (type.kind === "optional") return queryKeyType(type.value);
|
||||
return type.kind === "object-ref" || (type.kind === "scalar" && type.name !== "bytes");
|
||||
}
|
||||
|
||||
/** Paths are resolved once by the compiler. Runtime never resolves authored names. */
|
||||
export type QueryPathStep =
|
||||
| { kind: "source" | "target" }
|
||||
| {
|
||||
kind: "relation";
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
memberId: MemberId;
|
||||
targetInterfaceRevisionId: InterfaceRevisionId;
|
||||
optional: boolean;
|
||||
};
|
||||
export interface QueryExpression {
|
||||
path: QueryPathStep[];
|
||||
leaf:
|
||||
| { kind: "field"; interfaceRevisionId: InterfaceRevisionId; memberId: MemberId }
|
||||
| { kind: "ref"; interfaceRevisionId: InterfaceRevisionId }
|
||||
| { kind: "entry" | "mapKey" };
|
||||
type: ValueType;
|
||||
}
|
||||
export interface QueryReduction {
|
||||
operator: AggregateOperator;
|
||||
operand?: QueryExpression;
|
||||
type: ValueType;
|
||||
}
|
||||
export type QueryPredicate =
|
||||
| { kind: "and" | "or"; children: QueryPredicate[] }
|
||||
| { kind: "not"; child: QueryPredicate }
|
||||
| {
|
||||
kind: "compare";
|
||||
expression: QueryExpression;
|
||||
operator: "eq" | "in" | "isNull" | "lt" | "lte" | "gt" | "gte";
|
||||
value: QueryArgument;
|
||||
}
|
||||
| {
|
||||
kind: "relation";
|
||||
path: QueryPathStep[];
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
memberId: MemberId;
|
||||
targetInterfaceRevisionId: InterfaceRevisionId;
|
||||
operator: "some" | "none" | "is" | "isNull";
|
||||
predicate?: QueryPredicate;
|
||||
value?: QueryArgument;
|
||||
}
|
||||
| {
|
||||
kind: "reduce";
|
||||
path: QueryPathStep[];
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
memberId: MemberId;
|
||||
targetInterfaceRevisionId: InterfaceRevisionId;
|
||||
where?: QueryPredicate;
|
||||
having: QueryAggregatePredicate;
|
||||
};
|
||||
export type QueryAggregatePredicate =
|
||||
| { kind: "and" | "or"; children: QueryAggregatePredicate[] }
|
||||
| { kind: "not"; child: QueryAggregatePredicate }
|
||||
| {
|
||||
kind: "compare";
|
||||
expression: QueryExpression | QueryReduction;
|
||||
operator: "eq" | "in" | "isNull" | "lt" | "lte" | "gt" | "gte";
|
||||
value: QueryArgument;
|
||||
};
|
||||
export interface QueryKey {
|
||||
path: string[];
|
||||
expression: QueryExpression;
|
||||
}
|
||||
export interface QueryRelationalStage {
|
||||
id: number;
|
||||
input?: number;
|
||||
row: QueryRow;
|
||||
operation:
|
||||
| {
|
||||
kind: "source";
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
memberId: MemberId;
|
||||
targetInterfaceRevisionId: InterfaceRevisionId;
|
||||
}
|
||||
| { kind: "filter"; predicate: QueryPredicate }
|
||||
| {
|
||||
kind: "expand";
|
||||
path: QueryPathStep[];
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
memberId: MemberId;
|
||||
targetInterfaceRevisionId: InterfaceRevisionId;
|
||||
}
|
||||
| { kind: "distinct"; keys: QueryKey[] };
|
||||
}
|
||||
export interface QueryRelationalTerminal {
|
||||
stage: number;
|
||||
path: string[];
|
||||
kind: "aggregate" | "groups";
|
||||
keys: QueryKey[];
|
||||
reductions: { path: string[]; reduction: QueryReduction }[];
|
||||
where?: QueryPredicate;
|
||||
having?: QueryAggregatePredicate;
|
||||
order: { expression: QueryExpression | QueryReduction; descending: boolean }[];
|
||||
first?: QueryArgument;
|
||||
all?: QueryArgument;
|
||||
after?: QueryArgument;
|
||||
/** Projection tree remains separate from the operator plan. */
|
||||
selection: import("./types.js").QuerySelection[];
|
||||
residual: boolean;
|
||||
}
|
||||
export interface QueryRelationalPlan {
|
||||
stages: QueryRelationalStage[];
|
||||
terminals: QueryRelationalTerminal[];
|
||||
}
|
||||
export type QueryEffectRecorder = (
|
||||
id: InterfaceRevisionId,
|
||||
name: string,
|
||||
use: QueryUse,
|
||||
node: import("graphql").ASTNode,
|
||||
) => void;
|
||||
+28
-31
@@ -14,6 +14,7 @@ import {
|
||||
type GraphQLInputType,
|
||||
type GraphQLFieldConfigMap,
|
||||
type GraphQLInputFieldConfigMap,
|
||||
type DocumentNode,
|
||||
} from "graphql";
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
RelationshipInterfaceMember,
|
||||
} from "../capability-model/types.js";
|
||||
import { QueryCompileError, type QueryDeclaration } from "./types.js";
|
||||
import { relationalSchema, objectRow } from "./relational-schema.js";
|
||||
|
||||
// Validate losslessly; callers carry decimal strings across JSON transports.
|
||||
function integerParser(name: string, min: bigint, max: bigint, value: unknown): string {
|
||||
@@ -67,19 +69,21 @@ export const cursorScalar = new GraphQLScalarType({
|
||||
return value;
|
||||
},
|
||||
});
|
||||
export const referenceScalar = new GraphQLScalarType({ name: "ManagedReference" });
|
||||
|
||||
export function querySchema(declaration: QueryDeclaration, interfaces: readonly InterfaceRevision[]) {
|
||||
export function querySchema(
|
||||
declaration: QueryDeclaration,
|
||||
interfaces: readonly InterfaceRevision[],
|
||||
document?: DocumentNode,
|
||||
) {
|
||||
const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry]));
|
||||
const objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
|
||||
const filters = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
|
||||
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
|
||||
const byName = new Map<string, InterfaceRevision>();
|
||||
const comparisons = new Map<string, GraphQLInputObjectType>();
|
||||
const metadata = new GraphQLObjectType({
|
||||
name: "QxMetadata",
|
||||
fields: { ref: { type: new GraphQLNonNull(referenceScalar) } },
|
||||
});
|
||||
const scalarTypes = new Map<string, ValueType>(
|
||||
Object.entries(queryScalars).map(([name, type]) => [type.name, { kind: "scalar", name } as ValueType]),
|
||||
);
|
||||
scalarTypes.set("Cursor", { kind: "scalar", name: "string" });
|
||||
const pageInfo = new GraphQLObjectType({
|
||||
name: "QxPageInfo",
|
||||
fields: {
|
||||
@@ -130,6 +134,8 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
|
||||
let result = comparisons.get(key);
|
||||
if (!result) {
|
||||
const fields: GraphQLInputFieldConfigMap = { eq: { type: value }, isNull: { type: GraphQLBoolean } };
|
||||
if (base.kind === "scalar" && base.name !== "bytes")
|
||||
fields.in = { type: new GraphQLList(new GraphQLNonNull(value)) };
|
||||
if (base.kind === "scalar" && base.name !== "bool" && base.name !== "bytes")
|
||||
for (const op of ["lt", "lte", "gt", "gte"]) fields[op] = { type: value };
|
||||
result = new GraphQLInputObjectType({ name: `QxCompare${key}`, fields });
|
||||
@@ -137,29 +143,12 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const filter = (id: InterfaceRevisionId): GraphQLInputObjectType => {
|
||||
const existing = filters.get(id);
|
||||
if (existing) return existing;
|
||||
const result = new GraphQLInputObjectType({
|
||||
name: `${name(id)}Where`,
|
||||
fields: () => {
|
||||
const fields: GraphQLInputFieldConfigMap = {
|
||||
and: { type: new GraphQLList(new GraphQLNonNull(result)) },
|
||||
or: { type: new GraphQLList(new GraphQLNonNull(result)) },
|
||||
not: { type: result },
|
||||
};
|
||||
for (const member of contract(id).members)
|
||||
if (member.kind === "value" && member.queryRead) {
|
||||
if (member.displayName in fields)
|
||||
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${member.displayName}`);
|
||||
fields[member.displayName] = { type: comparison(member.valueType) };
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
});
|
||||
filters.set(id, result);
|
||||
return result;
|
||||
};
|
||||
const relational = relationalSchema(
|
||||
{ contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo },
|
||||
document,
|
||||
);
|
||||
relational.discover(declaration.root);
|
||||
const filter = (id: InterfaceRevisionId) => relational.where(objectRow(id));
|
||||
const order = (id: InterfaceRevisionId) => {
|
||||
let result = orders.get(id);
|
||||
if (!result) {
|
||||
@@ -181,6 +170,14 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
|
||||
const result = new GraphQLObjectType({
|
||||
name: name(id),
|
||||
fields: () => {
|
||||
const helpers = relational.helpers(id);
|
||||
const metadata = new GraphQLObjectType({
|
||||
name: `QxMetadata_${name(id)}`,
|
||||
fields: {
|
||||
ref: { type: new GraphQLNonNull(relational.refs(id)) },
|
||||
...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}),
|
||||
},
|
||||
});
|
||||
const fields: GraphQLFieldConfigMap<unknown, unknown> = { _qx: { type: new GraphQLNonNull(metadata) } };
|
||||
for (const member of contract(id).members) {
|
||||
if (member.kind === "operation" || !member.queryRead) continue;
|
||||
@@ -249,5 +246,5 @@ export function querySchema(declaration: QueryDeclaration, interfaces: readonly
|
||||
fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } },
|
||||
}),
|
||||
});
|
||||
return { schema, byName, target, contracts };
|
||||
return { schema, byName, target, contracts, relational, scalarTypes };
|
||||
}
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@ import type {
|
||||
ClosedTypeArgument,
|
||||
} from "../capability-model/generics.js";
|
||||
|
||||
export type QueryUse = "select" | "predicate" | "order";
|
||||
export type QueryUse = "select" | "predicate" | "order" | "aggregate" | "group" | "distinct";
|
||||
export interface QueryAllowance {
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
memberId: MemberId;
|
||||
@@ -106,4 +106,6 @@ export interface QuerySelection {
|
||||
conditions: { include: boolean; value: QueryArgument }[];
|
||||
arguments: Record<string, QueryArgument>;
|
||||
selection: QuerySelection[];
|
||||
relational?: import("./relational.js").QueryRelationalPlan;
|
||||
predicate?: import("./relational.js").QueryPredicate;
|
||||
}
|
||||
|
||||
Vendored
+71
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { compileCapabilityResourceSource } from "../../src/capability-language/index.js";
|
||||
import type { InterfaceRevision } from "../../src/capability-model/types.js";
|
||||
import { compileQuery } from "../../src/query/compile.js";
|
||||
|
||||
const source = { repository: "https://example.test/aggregation.git", commit: "a".repeat(40) };
|
||||
const interfaces: InterfaceRevision[] = [];
|
||||
function resource(text: string) {
|
||||
const result = compileCapabilityResourceSource(`external atom TaskObject id "task";\n${text}`, {
|
||||
source,
|
||||
environment: {
|
||||
interfaces: new Map(interfaces.map((i) => [i.displayName, i])),
|
||||
interfaceClosure: interfaces,
|
||||
},
|
||||
});
|
||||
assert.ok(result.ok, JSON.stringify(result.diagnostics));
|
||||
if (result.resource.kind === "interface") interfaces.push(result.resource.revision);
|
||||
return result.resource;
|
||||
}
|
||||
resource(`interface Person id "person" revision "person@1" {
|
||||
queryable value name id "name" : string { get id "name:get"; }
|
||||
}`);
|
||||
resource(`interface Tag id "tag" revision "tag@1" {
|
||||
queryable value color id "color" : string { get id "color:get"; }
|
||||
queryable relation tasks id "tag-tasks" : many atom TaskObject { resolve id "tag-tasks:resolve"; }
|
||||
}`);
|
||||
resource(`import interface Tag; import interface Person;
|
||||
interface Task id "task" revision "task@1" {
|
||||
queryable value estimatedHours id "hours" : optional<double> { get id "hours:get"; }
|
||||
queryable value cost id "cost" : int64 { get id "cost:get"; }
|
||||
queryable rpc value score id "score" : optional<double> { get id "score:get"; }
|
||||
queryable relation tags id "tags" : many-unique interface Tag { resolve id "tags:resolve"; }
|
||||
queryable relation assignee id "assignee" : optional-one interface Person { resolve id "assignee:resolve"; }
|
||||
}`);
|
||||
resource(`import interface Task;
|
||||
interface Tasks id "tasks" revision "tasks@1" {
|
||||
queryable relation tasks id "tasks" : many-unique interface Task { resolve id "tasks:resolve"; }
|
||||
queryable relation copies id "copies" : many interface Task ordered { resolve id "copies:resolve"; }
|
||||
queryable relation slots id "slots" : many interface Task keyed "int64" { resolve id "slots:resolve"; }
|
||||
}`);
|
||||
const pkg = resource(`import interface Tasks; import interface Task;
|
||||
package Reports id "reports" revision "reports@1" {
|
||||
query Report id "report" root Tasks document "report.graphql" operation "Report" {
|
||||
max rows 50;
|
||||
max result-bytes 1048576;
|
||||
max rpc-calls 100;
|
||||
max deadline-ms 10000;
|
||||
view TaskObject as Task;
|
||||
allow Task.score aggregate "Bounded aggregation fixture";
|
||||
allow Task.score predicate "Bounded aggregation fixture";
|
||||
allow Task.score order "Bounded aggregation fixture";
|
||||
allow Task.score group "Bounded aggregation fixture";
|
||||
allow Task.score distinct "Bounded aggregation fixture";
|
||||
}
|
||||
}`);
|
||||
if (pkg.kind !== "package") throw new Error("package expected");
|
||||
const declaration = pkg.revision.queries![0]!;
|
||||
export const compileAggregationDocument = (document: string) =>
|
||||
compileQuery(declaration, interfaces, async () => document);
|
||||
export const aggregationBindingSchema = (checked: Awaited<ReturnType<typeof compileQuery>>) => ({
|
||||
format: "quixos-bindings" as const,
|
||||
version: 1 as const,
|
||||
interfaces,
|
||||
packages: [{ ...pkg.revision, checkedQueries: [checked] }],
|
||||
});
|
||||
export const compileAggregation = (body: string, variables = "") =>
|
||||
compileQuery(
|
||||
declaration,
|
||||
interfaces,
|
||||
async () => `query Report${variables} { root { _qx { relations { tasks { ${body} } } } } }`,
|
||||
);
|
||||
Vendored
+9
@@ -55,6 +55,13 @@ export async function queryWorkspaceFixture() {
|
||||
allow TaskFacts.score predicate "Bounded local collection";
|
||||
allow TaskFacts.score order "Bounded local collection";
|
||||
}
|
||||
query Totals id "totals" root Collection<interface TaskFacts> document "totals.graphql" operation "Totals" {
|
||||
max rows 30; watch;
|
||||
}
|
||||
query ScoreTotals id "score-totals" root Collection<interface TaskFacts> document "score-totals.graphql" operation "ScoreTotals" {
|
||||
max rows 30; max candidates 60;
|
||||
allow TaskFacts.score aggregate "Complete bounded collection report";
|
||||
}
|
||||
}`;
|
||||
sources.Queries = packageSource;
|
||||
const pkgResult = compileCapabilityResourceSource(packageSource, {
|
||||
@@ -70,6 +77,8 @@ export async function queryWorkspaceFixture() {
|
||||
"row.graphql": `fragment TaskRow on TaskFacts {title rank done assignee {name}}`,
|
||||
"enriched.graphql": `query Enriched {root {items(first: 3) {entries {key node {_qx {ref} title score}}}}}`,
|
||||
"ranked.graphql": `query Ranked {root {items(first: 3, where: {score: {gt: 0}}, orderBy: [{score: DESC}]) {entries {key node {_qx {ref} title}}}}}`,
|
||||
"totals.graphql": `query Totals {root {_qx {relations {items {aggregate {count sum {rank}} groups(by: {done: true}, first: 10) {entries {group {done} aggregate {count sum {rank}}}}}}}}}`,
|
||||
"score-totals.graphql": `query ScoreTotals {root {_qx {relations {items {aggregate {count sum {score}}}}}}}`,
|
||||
};
|
||||
pkg.checkedQueries = await Promise.all(
|
||||
pkg.queries!.map((query) => compileQuery(query, allInterfaces, async (name) => documents[name]!)),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { compileAggregation as compile, compileAggregationDocument } from "./fixtures/query-aggregation.js";
|
||||
import type { QuerySelection } from "../src/query/types.js";
|
||||
import type { QueryRelationalPlan } from "../src/query/relational.js";
|
||||
|
||||
function findPlan(selections: QuerySelection[]): QueryRelationalPlan | undefined {
|
||||
for (const selection of selections) {
|
||||
const plan = selection.relational ?? findPlan(selection.selection);
|
||||
if (plan) return plan;
|
||||
}
|
||||
}
|
||||
|
||||
test("aggregate output uses exact count and sum types and nullable reductions", async () => {
|
||||
const checked = await compile(
|
||||
`aggregate { count countPresent { estimatedHours } sum { estimatedHours cost } avg { cost } }`,
|
||||
);
|
||||
const json = JSON.stringify(checked.output);
|
||||
assert.match(json, /"count":\{"kind":"scalar","name":"uint64"\}/);
|
||||
assert.match(json, /"cost":\{"kind":"optional","value":\{"kind":"scalar","name":"int64"\}\}/);
|
||||
assert.ok(checked.effects.some((e) => e.memberId === "hours" && e.uses.includes("aggregate")));
|
||||
const plan = findPlan(checked.selection);
|
||||
assert.ok(plan);
|
||||
assert.equal(plan.terminals[0]!.reductions.length, 5);
|
||||
});
|
||||
|
||||
test("explicit expansion and identity distinct retain checked grouped operands", async () => {
|
||||
const checked = await compile(`expand { tags {
|
||||
distinct(by: {source: {_qx: {ref: true}}, target: {color: true}}) {
|
||||
groups(by: {target: {color: true}}, first: 50,
|
||||
having: {sum: {source: {estimatedHours: {gt: 0}}}},
|
||||
orderBy: [{sum: {source: {estimatedHours: DESC}}}]) {
|
||||
entries { group {target {color}} aggregate {sum {source {estimatedHours}}} }
|
||||
pageInfo {hasNextPage endCursor}
|
||||
}
|
||||
}
|
||||
} }`);
|
||||
const plan = findPlan(checked.selection)!;
|
||||
assert.deepEqual(
|
||||
plan.stages.map((s) => s.operation.kind),
|
||||
["source", "expand", "distinct"],
|
||||
);
|
||||
assert.equal(plan.terminals[0]!.kind, "groups");
|
||||
assert.ok(
|
||||
checked.effects.some(
|
||||
(e) => e.memberId === "hours" && ["aggregate", "predicate", "order"].every((u) => e.uses.includes(u as never)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("distinct and grouping reject fields not determined by their selected keys", async () => {
|
||||
await assert.rejects(compile(`distinct(by: {cost: true}) {aggregate {sum {estimatedHours}}}`), /dropped by distinct/);
|
||||
await assert.rejects(compile(`groups(by: {cost: true}, first: 10) {entries {group {estimatedHours}}}`), /group key/);
|
||||
});
|
||||
|
||||
test("relationship existence predicates compile to typed plans", async () => {
|
||||
const checked = await compile(
|
||||
`filter(where: {_qx: {relations: {tags: {some: {color: {in: ["red","blue"]}}}}}}) {aggregate {count}}`,
|
||||
);
|
||||
const plan = findPlan(checked.selection)!;
|
||||
assert.equal(plan.stages[1]!.operation.kind, "filter");
|
||||
assert.ok(checked.effects.some((e) => e.memberId === "color" && e.uses.includes("predicate")));
|
||||
});
|
||||
|
||||
test("group structure is literal, bounded and cannot project a representative field", async () => {
|
||||
await assert.rejects(compile(`groups(by: {}, first: 2) {entries {aggregate {count}}}`), /key|empty/i);
|
||||
await assert.rejects(compile(`groups(by: {cost: false}, first: 2) {entries {aggregate {count}}}`), /true/);
|
||||
await assert.rejects(compile(`groups(by: {cost: true}) {entries {aggregate {count}}}`), /first or all/);
|
||||
await assert.rejects(
|
||||
compile(
|
||||
`groups(by: {cost: true}, first: 2, orderBy: [{sum: {cost: ASC, estimatedHours: DESC}}]) {entries {aggregate {count}}}`,
|
||||
),
|
||||
/exactly one field/,
|
||||
);
|
||||
await assert.rejects(compile(`expand {tags {aggregate {sum {target {color}}}}}`), /QUERY_VALIDATION/);
|
||||
});
|
||||
|
||||
test("RPC continuation rejection is local to the affected membership boundary", async () => {
|
||||
await assert.rejects(
|
||||
compile(`groups(by: {cost: true}, first: 2, after: null) {entries {aggregate {sum {score}}}}`),
|
||||
/without continuation/,
|
||||
);
|
||||
await assert.rejects(
|
||||
compileAggregationDocument(
|
||||
`query Report {root {tasks(first: 2, after: null, where: {score: {gt: 0}}) {entries {node {cost}}}}}`,
|
||||
),
|
||||
/without continuation/,
|
||||
);
|
||||
const checked = await compileAggregationDocument(`query Report($after: Cursor) {root {
|
||||
tasks(first: 2, after: $after) {entries {node {cost}}}
|
||||
_qx {relations {tasks {aggregate {sum {score}}}}}
|
||||
}}`);
|
||||
assert.equal(findPlan(checked.selection)!.terminals[0]!.residual, true);
|
||||
});
|
||||
|
||||
test("typed reference operands and scalar membership tests reject unbounded literal lists", async () => {
|
||||
const checked = await compile(
|
||||
`filter(where: {_qx: {ref: {in: $tasks}}}) {aggregate {count}}`,
|
||||
"($tasks: [QxRef_Task!]!)",
|
||||
);
|
||||
assert.match(JSON.stringify(checked.variables), /object-ref/);
|
||||
await assert.rejects(
|
||||
compile(`filter(where: {_qx: {ref: {eq: "obj:made-up"}}}) {aggregate {count}}`),
|
||||
/typed variables/,
|
||||
);
|
||||
await assert.rejects(
|
||||
compile(`filter(where: {cost: {in: [${Array(1001).fill("1").join(",")}]}}) {aggregate {count}}`),
|
||||
/1000 operands/,
|
||||
);
|
||||
});
|
||||
@@ -17,4 +17,13 @@ test("query fixture links generic collections, both implementations, and native
|
||||
assert.equal(title.kind, "value");
|
||||
delete title.queryRead;
|
||||
assert.throws(() => linkQueries(broken), /changed/);
|
||||
const aggregateBroken = structuredClone(workspace);
|
||||
const rank = aggregateBroken.interfaceImports
|
||||
.find((i) => i.displayName === "TaskFacts")!
|
||||
.members.find((m) => m.id === "rank")!;
|
||||
assert.equal(rank.kind, "value");
|
||||
delete rank.queryRead;
|
||||
assert.throws(() => linkQueries(aggregateBroken), /changed/);
|
||||
const totals = workspace.linkedQueries.find((q) => q.id.endsWith(":totals"))!;
|
||||
assert.equal(totals.fields.filter((f) => f.memberId === "rank").length, 2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user