@query-graph/core 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -496,6 +496,63 @@ JavaScript number и десятичная строка, а list-параметр
496
496
  типа. Эти проверки существуют только в декларациях TypeScript; сериализация
497
497
  definition, N-API вызов, runtime-валидация Rust и SQL compilation не меняются.
498
498
 
499
+ ### Тип результата проекции
500
+
501
+ DSL сохраняет scalar type и nullability каждого projection expression в compile-time metadata.
502
+ `ResultOf` строит из этих сведений вложенный тип результата по projection paths:
503
+
504
+ ```ts
505
+ import type { QueryOperation, ResultOf } from '@query-graph/core/dsl'
506
+
507
+ const operation = {
508
+ select: ['id', 'profile.displayName'],
509
+ parameters: { userId: 42 },
510
+ } as const satisfies QueryOperation<typeof definition>
511
+
512
+ type UserRow = ResultOf<typeof definition, typeof operation>
513
+ // {
514
+ // id: number | string
515
+ // profile: { displayName: string | null }
516
+ // }
517
+ ```
518
+
519
+ Если второй аргумент не передан, в результат входят только поля с `default: true`.
520
+ `ResultOf` также принимает тип, возвращенный `registerDefinition`, или mapped graph:
521
+
522
+ ```ts
523
+ type DefaultUserRow = ResultOf<typeof graph>
524
+ // { id: number | string }
525
+ ```
526
+
527
+ Nullability учитывает nullable-поле и всю цепочку optional relations до его source.
528
+ Required relation после optional relation не делает значение обязательным. Семантика
529
+ выражений тоже сохраняется: например, `coalesce(nullableField, 'fallback')` выводится
530
+ как non-null `string`.
531
+
532
+ Типы значений по умолчанию нейтральны к DB driver: `int64` и `decimal` представлены
533
+ как `number | string`, а `date`, `dateTime` и `binary` - как `string`. Адаптер
534
+ исполнения может передать третьим аргументом собственную полную scalar map:
535
+
536
+ ```ts
537
+ import type { DefaultScalarOutputTypeMap, ResultOf } from '@query-graph/core/dsl'
538
+
539
+ type DriverScalars = Omit<DefaultScalarOutputTypeMap, 'int64' | 'dateTime'> & {
540
+ int64: bigint
541
+ dateTime: Date
542
+ }
543
+
544
+ type DriverUserRow = ResultOf<typeof definition, typeof operation, DriverScalars>
545
+ ```
546
+
547
+ Для точного учета явного `select` operation должна сохранять literal tuple через
548
+ `as const` или `satisfies`. Это только TypeScript API: wire definition, N-API и
549
+ Rust planner не получают дополнительного runtime state.
550
+
551
+ `ResultOf` описывает логическую форму одной строки, восстановленную из путей
552
+ проекции. Он не выполняет запрос и не превращает relation `many` в массив.
553
+ Группировка строк, дедупликация и гидратация коллекций остаются ответственностью
554
+ потребителя модуля.
555
+
499
556
  ## Диагностика
500
557
 
501
558
  Ошибки N-API имеют имя `QueryGraphError` и сохраняют обычный `message` и stack.
@@ -659,3 +716,62 @@ relation `many` для него допустим. Для пагинации вл
659
716
 
660
717
  Полный пример графа, отображения, операции, сгенерированного SQL и замера
661
718
  производительности компиляции находится в `benchmark/bench.ts`.
719
+
720
+ ## Batch-связи и двухфазное выполнение
721
+
722
+ Поля из отдельного запроса подключаются без изменения обычного relational-графа:
723
+
724
+ ```ts
725
+ const childrenQuery = batchQuery({
726
+ graph: childrenGraph,
727
+ key: {
728
+ path: 'parentId',
729
+ parameter: parentIds,
730
+ },
731
+ })
732
+
733
+ const children = batchRelation({
734
+ name: 'children',
735
+ from: 'id',
736
+ query: childrenQuery,
737
+ cardinality: 'many',
738
+ })
739
+
740
+ const graph = composeGraph({ root: rootGraph, relations: [children] })
741
+ const plan = graph.compileOraclePlan({
742
+ select: ['id', 'children.id'],
743
+ parameters: {},
744
+ limit: 20,
745
+ })
746
+ ```
747
+
748
+ `batchQuery` принадлежит child-модулю и один раз закрепляет его graph,
749
+ projection path ключа и list-параметр. Поэтому разные root-графы могут
750
+ переиспользовать query, не повторяя внутренний контракт child-запроса.
751
+
752
+ `composeGraph` оставляет в JavaScript только декларативный фасад. Проверка
753
+ совместимости ключей и параметров, разделение operation на root и batch-шаги и
754
+ компиляция SQL выполняются в Rust. `compileOraclePlan` или
755
+ `compileSqlServerPlan` сразу компилирует только `plan.root`; child SQL создаётся
756
+ при вызове `compileBatch`. Plan сохраняет выбранные dialect и version, поэтому
757
+ отложенный statement компилируется с теми же настройками, что и root.
758
+
759
+ Для подготовки child bindings каждый шаг в `plan.batches` также содержит имя
760
+ ключевого параметра `keyParameter` и снимок статических `parameters`. Executor
761
+ объединяет их с `{ [batch.keyParameter]: keys }` и сопоставляет полученные значения
762
+ с возвращёнными `statement.bindings`.
763
+
764
+ Executor потребителя выполняет `plan.root`, собирает уникальные ненулевые
765
+ значения `parentKey` из `plan.batches`, вызывает
766
+ `plan.compileBatch('children', parentIds)`, выполняет child statement и
767
+ связывает строки по `childKey`. `cardinality: 'one'` означает объект или `null`,
768
+ а `many` — массив. Query-graph не подключается к БД, не дедуплицирует ключи, не
769
+ выполняет statements и не гидратирует результат; при пустом наборе ключей
770
+ executor должен пропустить child-запрос.
771
+
772
+ Ordering, offset и limit корневой operation применяются только к `plan.root`.
773
+ Batch-связь может задать собственный `ordering`, но child pagination не
774
+ добавляется. Batch-поля выбираются только явно (`children.id`); без prefixed
775
+ path соответствующего шага в `plan.batches` нет. Флаги injection в metadata
776
+ показывают executor, какие ключи были добавлены исключительно для связывания и
777
+ не должны попасть в публичный результат.
package/composition.js ADDED
@@ -0,0 +1,126 @@
1
+ 'use strict'
2
+
3
+ const BATCH_QUERY_KEYS = new Set(['graph', 'key'])
4
+ const BATCH_QUERY_KEY_KEYS = new Set(['path', 'parameter'])
5
+ const BATCH_RELATION_KEYS = new Set(['name', 'from', 'query', 'cardinality', 'parameters', 'ordering'])
6
+ const COMPOSE_KEYS = new Set(['root', 'relations'])
7
+ const batchQueryBrand = Symbol('BatchQuery')
8
+ const batchBrand = Symbol('BatchRelation')
9
+
10
+ function configurationOf(factory, value, allowedKeys) {
11
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
12
+ throw new TypeError(`${factory} expects a configuration object`)
13
+ }
14
+
15
+ const unknownKeys = Reflect.ownKeys(value).filter((key) => typeof key !== 'string' || !allowedKeys.has(key))
16
+ if (unknownKeys.length > 0) {
17
+ const label = unknownKeys.length === 1 ? 'field' : 'fields'
18
+ const keys = unknownKeys.map((key) => JSON.stringify(String(key))).join(', ')
19
+ throw new TypeError(`${factory} received unknown configuration ${label} ${keys}`)
20
+ }
21
+
22
+ return value
23
+ }
24
+
25
+ function nonEmptyString(value, label) {
26
+ if (typeof value !== 'string' || value.trim() === '') {
27
+ throw new TypeError(`${label} must be a non-empty string`)
28
+ }
29
+ return value
30
+ }
31
+
32
+ function relationalGraph(value, label) {
33
+ if (value === null || typeof value !== 'object' || typeof value.compose !== 'function') {
34
+ throw new TypeError(`${label} must be a RelationalQueryGraph`)
35
+ }
36
+ return value
37
+ }
38
+
39
+ function batchQueryOf(value, label) {
40
+ if (value === null || typeof value !== 'object' || value[batchQueryBrand] !== true) {
41
+ throw new TypeError(`${label} must be created by batchQuery`)
42
+ }
43
+ return value
44
+ }
45
+
46
+ function batchQuery(value) {
47
+ const configuration = configurationOf('batchQuery', value, BATCH_QUERY_KEYS)
48
+ const graph = relationalGraph(configuration.graph, 'batchQuery.graph')
49
+ const keyConfiguration = configurationOf('batchQuery.key', configuration.key, BATCH_QUERY_KEY_KEYS)
50
+ const path = nonEmptyString(keyConfiguration.path, 'batchQuery.key.path')
51
+ const parameter = nonEmptyString(
52
+ typeof keyConfiguration.parameter === 'string' ? keyConfiguration.parameter : keyConfiguration.parameter?.name,
53
+ 'batchQuery.key.parameter',
54
+ )
55
+
56
+ return Object.freeze({
57
+ graph,
58
+ key: Object.freeze({
59
+ path,
60
+ parameter,
61
+ }),
62
+ [batchQueryBrand]: true,
63
+ })
64
+ }
65
+
66
+ function batchRelation(value) {
67
+ const configuration = configurationOf('batchRelation', value, BATCH_RELATION_KEYS)
68
+ const name = nonEmptyString(configuration.name, 'batchRelation.name')
69
+ if (name.includes('.')) {
70
+ throw new TypeError('batchRelation.name must be a single projection path segment')
71
+ }
72
+
73
+ const from = nonEmptyString(configuration.from, 'batchRelation.from')
74
+ const query = batchQueryOf(configuration.query, 'batchRelation.query')
75
+
76
+ if (configuration.cardinality !== 'one' && configuration.cardinality !== 'many') {
77
+ throw new TypeError("batchRelation.cardinality must be 'one' or 'many'")
78
+ }
79
+ if (
80
+ configuration.parameters !== undefined &&
81
+ (configuration.parameters === null ||
82
+ typeof configuration.parameters !== 'object' ||
83
+ Array.isArray(configuration.parameters))
84
+ ) {
85
+ throw new TypeError('batchRelation.parameters must be an object')
86
+ }
87
+ if (configuration.ordering !== undefined) {
88
+ nonEmptyString(configuration.ordering, 'batchRelation.ordering')
89
+ }
90
+
91
+ return Object.freeze({
92
+ name,
93
+ from,
94
+ query,
95
+ cardinality: configuration.cardinality,
96
+ parameters: Object.freeze({ ...configuration.parameters }),
97
+ ...(configuration.ordering === undefined ? {} : { ordering: configuration.ordering }),
98
+ [batchBrand]: true,
99
+ })
100
+ }
101
+
102
+ function composeGraph(value) {
103
+ const configuration = configurationOf('composeGraph', value, COMPOSE_KEYS)
104
+ const root = relationalGraph(configuration.root, 'composeGraph.root')
105
+ if (!Array.isArray(configuration.relations)) {
106
+ throw new TypeError('composeGraph.relations must be an array')
107
+ }
108
+
109
+ return configuration.relations.reduce((graph, relation, index) => {
110
+ if (relation?.[batchBrand] !== true) {
111
+ throw new TypeError(`composeGraph.relations[${index}] must be created by batchRelation`)
112
+ }
113
+
114
+ return graph.withBatchRelation(relation.query.graph, {
115
+ name: relation.name,
116
+ from: relation.from,
117
+ to: relation.query.key.path,
118
+ parameter: relation.query.key.parameter,
119
+ cardinality: relation.cardinality,
120
+ parameters: relation.parameters,
121
+ ...(relation.ordering === undefined ? {} : { ordering: relation.ordering }),
122
+ })
123
+ }, root.compose())
124
+ }
125
+
126
+ module.exports = { batchQuery, batchRelation, composeGraph }
package/definition.d.ts CHANGED
@@ -2,19 +2,27 @@ export * from './dsl.js'
2
2
 
3
3
  import type {
4
4
  ConstraintDefinition,
5
- DimensionDefinition,
6
5
  Expression,
7
6
  ExpressionInput,
7
+ ExpressionNullability,
8
+ ExpressionScalarType,
8
9
  FieldExpression,
10
+ FieldSpecIsNullable,
9
11
  FieldSpecMap,
12
+ FieldSpecScalarType,
10
13
  JoinProjectionPath,
11
- MeasureDefinition,
12
14
  ParameterRef,
13
- ProjectionFieldDefinition,
14
15
  RelationCardinality,
15
16
  RelationRef,
16
17
  RelationSelection,
18
+ ScalarType,
19
+ SourceFieldNullability,
20
+ SourceReferenceKey,
17
21
  SourceRef,
22
+ TypedExpression,
23
+ TypedDimensionDefinition,
24
+ TypedMeasureDefinition,
25
+ TypedProjectionField,
18
26
  } from './dsl.js'
19
27
 
20
28
  export const GRAPH_DEFINITION_VERSION: 9
@@ -23,25 +31,41 @@ export function field<
23
31
  const Key extends string,
24
32
  const Fields extends FieldSpecMap,
25
33
  const Name extends Extract<keyof Fields, string>,
26
- >(source: SourceRef<Key, Fields>, name: Name): FieldExpression<Key, Name>
34
+ >(
35
+ source: SourceRef<Key, Fields>,
36
+ name: Name,
37
+ ): TypedExpression<
38
+ FieldExpression<Key, Name>,
39
+ FieldSpecScalarType<Fields[Name]>,
40
+ SourceFieldNullability<Key, FieldSpecIsNullable<Fields[Name]>>
41
+ >
27
42
  export function field<const Source extends string, const Name extends string>(
28
43
  source: Source,
29
44
  name: Name,
30
- ): FieldExpression<Source, Name>
45
+ ): TypedExpression<FieldExpression<Source, Name>, ScalarType, SourceFieldNullability<Source, boolean>>
31
46
 
32
- export interface RelationOptions {
33
- required?: boolean
34
- cardinality?: RelationCardinality
47
+ export interface RelationOptions<
48
+ Required extends boolean = boolean,
49
+ Cardinality extends RelationCardinality = RelationCardinality,
50
+ > {
51
+ required?: Required
52
+ cardinality?: Cardinality
35
53
  selection?: RelationSelection
36
54
  }
37
55
 
38
- export function relation<const Name extends string>(
56
+ export function relation<
57
+ const Name extends string,
58
+ const From extends string | SourceRef,
59
+ const To extends string | SourceRef,
60
+ const Required extends boolean = false,
61
+ const Cardinality extends RelationCardinality = 'one',
62
+ >(
39
63
  name: Name,
40
- from: string | SourceRef,
41
- to: string | SourceRef,
64
+ from: From,
65
+ to: To,
42
66
  on: Expression,
43
- options?: RelationOptions,
44
- ): RelationRef<Name>
67
+ options?: RelationOptions<Required, Cardinality>,
68
+ ): RelationRef<Name, SourceReferenceKey<From>, SourceReferenceKey<To>, Required, Cardinality>
45
69
 
46
70
  export interface ConstraintOptions {
47
71
  when?: string | ParameterRef
@@ -49,39 +73,93 @@ export interface ConstraintOptions {
49
73
 
50
74
  export function constraint(predicate: Expression, options?: ConstraintOptions): ConstraintDefinition
51
75
 
52
- export interface ProjectionOptions {
53
- default?: boolean
76
+ export interface ProjectionOptions<SelectedByDefault extends boolean = boolean> {
77
+ default?: SelectedByDefault
54
78
  }
55
79
 
56
- export function project<const Path extends string>(
80
+ export function project<
81
+ const Path extends string,
82
+ const Value extends ExpressionInput,
83
+ const SelectedByDefault extends boolean = false,
84
+ >(
57
85
  path: Path,
58
- expression: ExpressionInput,
59
- options?: ProjectionOptions,
60
- ): ProjectionFieldDefinition<Path>
61
- export function project<const Path extends readonly string[]>(
86
+ expression: Value,
87
+ options?: ProjectionOptions<SelectedByDefault>,
88
+ ): TypedProjectionField<
89
+ Path,
90
+ Extract<ExpressionScalarType<Value>, ScalarType>,
91
+ ExpressionNullability<Value>,
92
+ SelectedByDefault
93
+ >
94
+ export function project<
95
+ const Path extends readonly string[],
96
+ const Value extends ExpressionInput,
97
+ const SelectedByDefault extends boolean = false,
98
+ >(
62
99
  path: Path,
63
- expression: ExpressionInput,
64
- options?: ProjectionOptions,
65
- ): ProjectionFieldDefinition<JoinProjectionPath<Path>>
100
+ expression: Value,
101
+ options?: ProjectionOptions<SelectedByDefault>,
102
+ ): TypedProjectionField<
103
+ JoinProjectionPath<Path>,
104
+ Extract<ExpressionScalarType<Value>, ScalarType>,
105
+ ExpressionNullability<Value>,
106
+ SelectedByDefault
107
+ >
66
108
 
67
- export function dimension<const Path extends string>(
109
+ export function dimension<
110
+ const Path extends string,
111
+ const Value extends ExpressionInput,
112
+ const SelectedByDefault extends boolean = false,
113
+ >(
68
114
  path: Path,
69
- expression: ExpressionInput,
70
- options?: ProjectionOptions,
71
- ): DimensionDefinition<Path>
72
- export function dimension<const Path extends readonly string[]>(
115
+ expression: Value,
116
+ options?: ProjectionOptions<SelectedByDefault>,
117
+ ): TypedDimensionDefinition<
118
+ Path,
119
+ Extract<ExpressionScalarType<Value>, ScalarType>,
120
+ ExpressionNullability<Value>,
121
+ SelectedByDefault
122
+ >
123
+ export function dimension<
124
+ const Path extends readonly string[],
125
+ const Value extends ExpressionInput,
126
+ const SelectedByDefault extends boolean = false,
127
+ >(
73
128
  path: Path,
74
- expression: ExpressionInput,
75
- options?: ProjectionOptions,
76
- ): DimensionDefinition<JoinProjectionPath<Path>>
129
+ expression: Value,
130
+ options?: ProjectionOptions<SelectedByDefault>,
131
+ ): TypedDimensionDefinition<
132
+ JoinProjectionPath<Path>,
133
+ Extract<ExpressionScalarType<Value>, ScalarType>,
134
+ ExpressionNullability<Value>,
135
+ SelectedByDefault
136
+ >
77
137
 
78
- export function measure<const Path extends string>(
138
+ export function measure<
139
+ const Path extends string,
140
+ const Value extends ExpressionInput,
141
+ const SelectedByDefault extends boolean = false,
142
+ >(
79
143
  path: Path,
80
- expression: ExpressionInput,
81
- options?: ProjectionOptions,
82
- ): MeasureDefinition<Path>
83
- export function measure<const Path extends readonly string[]>(
144
+ expression: Value,
145
+ options?: ProjectionOptions<SelectedByDefault>,
146
+ ): TypedMeasureDefinition<
147
+ Path,
148
+ Extract<ExpressionScalarType<Value>, ScalarType>,
149
+ ExpressionNullability<Value>,
150
+ SelectedByDefault
151
+ >
152
+ export function measure<
153
+ const Path extends readonly string[],
154
+ const Value extends ExpressionInput,
155
+ const SelectedByDefault extends boolean = false,
156
+ >(
84
157
  path: Path,
85
- expression: ExpressionInput,
86
- options?: ProjectionOptions,
87
- ): MeasureDefinition<JoinProjectionPath<Path>>
158
+ expression: Value,
159
+ options?: ProjectionOptions<SelectedByDefault>,
160
+ ): TypedMeasureDefinition<
161
+ JoinProjectionPath<Path>,
162
+ Extract<ExpressionScalarType<Value>, ScalarType>,
163
+ ExpressionNullability<Value>,
164
+ SelectedByDefault
165
+ >
package/definition.js CHANGED
@@ -102,6 +102,9 @@ exports.relation = relation
102
102
  exports.constraint = constraint
103
103
  exports.ordering = objectDsl.ordering
104
104
  exports.project = project
105
+ exports.batchQuery = objectDsl.batchQuery
106
+ exports.batchRelation = objectDsl.batchRelation
107
+ exports.composeGraph = objectDsl.composeGraph
105
108
  exports.dimension = dimension
106
109
  exports.measure = measure
107
110
  exports.asc = objectDsl.asc