@query-graph/core 1.1.0 → 1.1.2

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
@@ -1,11 +1,189 @@
1
1
  # query-graph
2
2
 
3
- Нативное планирование графов запросов и компиляция SQL для Node.js.
3
+ Типизированное описание графов запросов, нативное планирование и компиляция SQL
4
+ для Node.js.
4
5
 
5
- Пакет отвечает за валидацию графа, планирование и генерацию SQL. Подключение к
6
- базе данных и исполнение запросов остаются ответственностью потребителя.
7
- Один и тот же граф и реляционное отображение могут компилироваться в SQL Server
8
- или Oracle SQL.
6
+ `query-graph` позволяет один раз описать доступные данные и связи без SQL, а для
7
+ каждой операции выбрать поля, параметры, сортировку и пагинацию. Rust проверяет
8
+ definition, строит минимальный план связей и компилирует его в SQL Server или
9
+ Oracle SQL.
10
+
11
+ Пакет намеренно **не подключается к базе данных и не выполняет SQL**. Он
12
+ возвращает SQL, bindings и metadata результата; способ исполнения остаётся у
13
+ приложения.
14
+
15
+ Основные возможности:
16
+
17
+ - строгий TypeScript DSL для sources, parameters, relations и projections;
18
+ - runtime-валидация definition и operation в Rust;
19
+ - выбор только необходимых JOIN по запрошенным projection paths;
20
+ - один definition для SQL Server и Oracle;
21
+ - scalar/list parameters без подстановки значений в SQL;
22
+ - `exists`, детерминированные to-one relations, summary-графы и batch relations;
23
+ - тип результата через `ResultOf`.
24
+
25
+ Требуется Node.js 20 или новее.
26
+
27
+ ## Быстрый старт
28
+
29
+ ### Установка
30
+
31
+ ```bash
32
+ npm install @query-graph/core
33
+ ```
34
+
35
+ Основной объектный DSL уже входит в пакет и доступен через
36
+ `@query-graph/core/dsl`.
37
+
38
+ ### 1. Опишите доступные данные
39
+
40
+ Definition использует логические имена. Здесь нет имён таблиц, SQL-алиасов или
41
+ синтаксиса конкретной СУБД.
42
+
43
+ ```ts
44
+ import { registerDefinition } from '@query-graph/core'
45
+ import { constraint, defineGraph, eq, param, project, requiredParameter, source } from '@query-graph/core/dsl'
46
+ import type { QueryOperation, ResultOf } from '@query-graph/core/dsl'
47
+
48
+ const users = source('users', {
49
+ id: 'int64',
50
+ organisationId: 'int64',
51
+ email: 'string',
52
+ })
53
+
54
+ const organisationId = requiredParameter('organisationId', 'int64')
55
+
56
+ const usersDefinition = defineGraph({
57
+ name: 'users',
58
+ root: users,
59
+ sources: [users],
60
+ parameters: [organisationId],
61
+ constraints: [
62
+ constraint({
63
+ predicate: eq(users.field('organisationId'), param(organisationId)),
64
+ }),
65
+ ],
66
+ projection: [
67
+ project({ path: 'id', expression: users.field('id'), default: true }),
68
+ project({ path: 'email', expression: users.field('email'), default: true }),
69
+ ],
70
+ })
71
+ ```
72
+
73
+ ### 2. Добавьте реляционное отображение
74
+
75
+ Mapping связывает логический source с физической таблицей. Его можно заменить,
76
+ не меняя definition.
77
+
78
+ ```ts
79
+ const usersGraph = registerDefinition(usersDefinition).withRelationalMapping({
80
+ sources: {
81
+ users: {
82
+ table: { schema: 'dbo', name: 'users' },
83
+ columns: {
84
+ organisationId: 'organisation_id',
85
+ },
86
+ },
87
+ },
88
+ })
89
+ ```
90
+
91
+ `registerDefinition` валидирует и индексирует definition в Rust. Обычно граф
92
+ регистрируется один раз при запуске приложения и затем переиспользуется.
93
+
94
+ ### 3. Скомпилируйте операцию
95
+
96
+ Operation содержит только изменяемую часть запроса. TypeScript проверяет имена
97
+ полей и параметров относительно конкретного definition.
98
+
99
+ ```ts
100
+ const operation = {
101
+ select: ['id', 'email'],
102
+ parameters: {
103
+ organisationId: 42,
104
+ },
105
+ } as const satisfies QueryOperation<typeof usersDefinition>
106
+
107
+ const statement = usersGraph.compileSqlServer(operation)
108
+
109
+ console.log(statement.sql)
110
+ console.table(statement.bindings)
111
+ console.table(statement.columns)
112
+
113
+ type UserRow = ResultOf<typeof usersDefinition, typeof operation>
114
+ // { id: number | string; email: string }
115
+ ```
116
+
117
+ Для SQL Server получится параметризованный запрос:
118
+
119
+ ```sql
120
+ SELECT
121
+ [t0].[id] AS [c0],
122
+ [t0].[email] AS [c1]
123
+ FROM [dbo].[users] AS [t0]
124
+ WHERE
125
+ ([t0].[organisation_id] = @p0)
126
+ ```
127
+
128
+ В `statement.bindings` placeholder `p0` будет связан с параметром
129
+ `organisationId` типа `int64`.
130
+
131
+ Тот же graph можно передать другому compiler:
132
+
133
+ ```ts
134
+ const oracleStatement = usersGraph.compileOracle(operation, { version: '19c' })
135
+ ```
136
+
137
+ ### 4. Выполните SQL своим драйвером
138
+
139
+ Результат компиляции не содержит соединение с базой данных:
140
+
141
+ | Поле statement | Назначение |
142
+ | -------------- | ------------------------------------------------------------------------------ |
143
+ | `sql` | SQL с именованными placeholders `p0`, `p1` и так далее |
144
+ | `bindings` | связь placeholder с параметром operation, его типом и индексом элемента списка |
145
+ | `columns` | связь физической колонки `cN` с projection path и scalar type |
146
+ | `objects` | presence-маркеры для nullable вложенных объектов |
147
+ | `relations` | связи, фактически выбранные planner для этой операции |
148
+
149
+ Executor приложения берёт значения из `operation.parameters` согласно
150
+ `statement.bindings`, передаёт их DB driver и восстанавливает логические поля по
151
+ `statement.columns`. Разбирать SQL для этого не требуется.
152
+
153
+ ## Как выбрать возможность
154
+
155
+ | Задача | API или раздел |
156
+ | -------------------------------------------------------- | ----------------------------------------------------------- |
157
+ | Выбрать связанные поля `0..1` или `1` | [`relation` и `firstBy`](#to-one-selection) |
158
+ | Отфильтровать root по наличию связанных строк | [`exists`](#existential-constraints) |
159
+ | Переиспользовать часть definition | [GraphModule](#композиция-через-graphmodule) |
160
+ | Передать массив в `IN (...)` | [Параметры-списки](#параметры-списки) |
161
+ | Выбирать сортировку во время выполнения | [Именованные сортировки](#именованные-сортировки) |
162
+ | Получить агрегированный результат | [Summary-графы](#summary-графы) |
163
+ | Отличить отсутствующий объект от объекта с `NULL`-полями | [Presence объектной проекции](#presence-объектной-проекции) |
164
+ | Загрузить коллекцию после пагинации root | [Batch-связи](#batch-связи-и-двухфазное-выполнение) |
165
+ | Обработать структурированную ошибку | [Диагностика](#диагностика) |
166
+
167
+ ## Модель использования
168
+
169
+ | Понятие | Когда создаётся | За что отвечает |
170
+ | ---------------------- | ----------------------------------- | ------------------------------------------------------- |
171
+ | `GraphDefinition` | при объявлении графа | логические sources, relations, constraints и projection |
172
+ | `QueryGraph` | один раз после `registerDefinition` | проверенный и проиндексированный нативный граф |
173
+ | `RelationalMapping` | при конфигурации приложения | физические таблицы и переименования колонок |
174
+ | `QueryOperation` | для конкретного запроса | выбранные поля, параметры, ordering и pagination |
175
+ | `CompiledSqlStatement` | при компиляции operation | SQL и metadata, необходимые executor приложения |
176
+ | `GraphModule` | при композиции definition | переиспользуемый фрагмент без собственного root |
177
+
178
+ Обычный жизненный цикл выглядит так:
179
+
180
+ ```text
181
+ defineGraph -> registerDefinition -> withRelationalMapping
182
+ |
183
+ QueryOperation -----------------------+-> compileSqlServer / compileOracle
184
+ |
185
+ +-> executor приложения -> DB driver
186
+ ```
9
187
 
10
188
  ## Архитектура пакета
11
189
 
@@ -119,6 +297,8 @@ const definition = defineGraph({
119
297
  const graph = registerDefinition(definition)
120
298
  ```
121
299
 
300
+ ### Композиция через GraphModule
301
+
122
302
  `GraphModule` группирует переиспользуемую часть определения: источники,
123
303
  параметры, связи, ограничения, проекцию и сортировку. У модуля нет собственного
124
304
  корневого источника, и его нельзя зарегистрировать или скомпилировать отдельно.
@@ -579,6 +759,64 @@ try {
579
759
  Wire errors, semantic validation и SQL compilation имеют разные верхнеуровневые
580
760
  `code`. Коды отдельных `issues` стабильны и не требуют разбора текста `message`.
581
761
 
762
+ ## Presence объектной проекции
763
+
764
+ Nullable-поля не позволяют определить, отсутствует ли вложенный объект: существующий
765
+ объект может корректно содержать только `NULL`. Для такой границы definition явно
766
+ задаёт выражение присутствия через `projectObject`:
767
+
768
+ ```ts
769
+ const definition = defineGraph({
770
+ name: 'users',
771
+ root: users,
772
+ sources: [users, profiles],
773
+ relations: [
774
+ relation({
775
+ name: 'profile',
776
+ from: users,
777
+ to: profiles,
778
+ on: eq(users.field('id'), profiles.field('userId')),
779
+ }),
780
+ ],
781
+ objects: [
782
+ projectObject({
783
+ path: 'profile',
784
+ presence: profiles.field('id'),
785
+ }),
786
+ ],
787
+ projection: [
788
+ project({
789
+ path: 'profile.displayName',
790
+ expression: profiles.field('displayName'),
791
+ }),
792
+ ],
793
+ })
794
+ ```
795
+
796
+ `presence` должно возвращать не-`NULL`, когда объект существует, и `NULL`, когда
797
+ его нет. Обычно для этого используется обязательный идентификатор source за
798
+ optional relation. Маркер не является доступным для выбора полем проекции.
799
+
800
+ Если operation выбирает хотя бы одно поле под `profile`, planner включает
801
+ presence-выражение в план. SQL compiler добавляет скрытую колонку, например `o0`,
802
+ и возвращает отдельные metadata:
803
+
804
+ ```ts
805
+ statement.objects
806
+ // [{ path: 'profile', presenceColumn: 'o0' }]
807
+ ```
808
+
809
+ `statement.columns` по-прежнему содержит только публичные поля `c0`, `c1` и так
810
+ далее. Исполнитель сначала восстанавливает результат по `statement.columns`, а
811
+ затем устанавливает объект по `path` в `null`, если соответствующая исходная
812
+ колонка `presenceColumn` равна `NULL`. Для вложенных объектов metadata упорядочены
813
+ от самого глубокого path к внешнему.
814
+
815
+ Таким образом, исполнитель не угадывает отсутствие объекта по правилу «все поля
816
+ равны `NULL`». `ResultOf` также учитывает declaration и выводит
817
+ `profile: { ... } | null`. Пакет только компилирует SQL и metadata; выполнение
818
+ запроса и materialization остаются у потребителя.
819
+
582
820
  ## SQL Server
583
821
 
584
822
  Реляционное отображение связывает логические источники с физическими таблицами.
@@ -716,3 +954,68 @@ relation `many` для него допустим. Для пагинации вл
716
954
 
717
955
  Полный пример графа, отображения, операции, сгенерированного SQL и замера
718
956
  производительности компиляции находится в `benchmark/bench.ts`.
957
+
958
+ ## Batch-связи и двухфазное выполнение
959
+
960
+ Поля из отдельного запроса подключаются без изменения обычного relational-графа:
961
+
962
+ ```ts
963
+ const childrenQuery = batchQuery({
964
+ graph: childrenGraph,
965
+ key: {
966
+ path: 'parentId',
967
+ parameter: parentIds,
968
+ },
969
+ })
970
+
971
+ const children = batchRelation({
972
+ name: 'children',
973
+ from: 'id',
974
+ query: childrenQuery,
975
+ cardinality: 'many',
976
+ })
977
+
978
+ const graph = composeGraph({ root: rootGraph, relations: [children] })
979
+ const plan = graph.compileOraclePlan({
980
+ select: ['id', 'children.id'],
981
+ parameters: {},
982
+ limit: 20,
983
+ })
984
+ ```
985
+
986
+ `batchQuery` принадлежит child-модулю и один раз закрепляет его graph,
987
+ projection path ключа и list-параметр. Поэтому разные root-графы могут
988
+ переиспользовать query, не повторяя внутренний контракт child-запроса.
989
+
990
+ `composeGraph` оставляет в JavaScript только декларативный фасад. Проверка
991
+ совместимости ключей и параметров, разделение operation на root и batch-шаги и
992
+ компиляция SQL выполняются в Rust. `compileOraclePlan` или
993
+ `compileSqlServerPlan` сразу компилирует только `plan.root`; child SQL создаётся
994
+ при вызове `compileBatch`. Plan сохраняет выбранные dialect и version, поэтому
995
+ отложенный statement компилируется с теми же настройками, что и root.
996
+
997
+ Для подготовки child bindings каждый шаг в `plan.batches` также содержит имя
998
+ ключевого параметра `keyParameter` и снимок статических `parameters`. Executor
999
+ объединяет их с `{ [batch.keyParameter]: keys }` и сопоставляет полученные значения
1000
+ с возвращёнными `statement.bindings`.
1001
+
1002
+ Executor потребителя выполняет `plan.root`, собирает уникальные ненулевые
1003
+ значения `parentKey` из `plan.batches`, вызывает
1004
+ `plan.compileBatch('children', parentIds)`, выполняет child statement и
1005
+ связывает строки по `childKey`. `cardinality: 'one'` означает объект или `null`,
1006
+ а `many` — массив. Query-graph не подключается к БД, не дедуплицирует ключи, не
1007
+ выполняет statements и не гидратирует результат; при пустом наборе ключей
1008
+ executor должен пропустить child-запрос.
1009
+
1010
+ Ordering, offset и limit корневой operation применяются только к `plan.root`.
1011
+ Batch-связь может задать собственный `ordering`, но child pagination не
1012
+ добавляется. Batch-поля выбираются только явно (`children.id`); без prefixed
1013
+ path соответствующего шага в `plan.batches` нет. Флаги injection в metadata
1014
+ показывают executor, какие ключи были добавлены исключительно для связывания и
1015
+ не должны попасть в публичный результат.
1016
+
1017
+ ---
1018
+
1019
+ ### Rule 34
1020
+
1021
+ > Every sufficiently generic abstraction eventually gets an implementation.
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
@@ -25,7 +25,7 @@ import type {
25
25
  TypedProjectionField,
26
26
  } from './dsl.js'
27
27
 
28
- export const GRAPH_DEFINITION_VERSION: 9
28
+ export const GRAPH_DEFINITION_VERSION: 10
29
29
 
30
30
  export function field<
31
31
  const Key extends string,
package/definition.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const objectDsl = require('./dsl.js')
4
4
 
5
- const GRAPH_DEFINITION_VERSION = 9
5
+ const GRAPH_DEFINITION_VERSION = 10
6
6
 
7
7
  function field(sourceReference, name) {
8
8
  if (typeof sourceReference === 'string') {
@@ -102,6 +102,10 @@ exports.relation = relation
102
102
  exports.constraint = constraint
103
103
  exports.ordering = objectDsl.ordering
104
104
  exports.project = project
105
+ exports.projectObject = objectDsl.projectObject
106
+ exports.batchQuery = objectDsl.batchQuery
107
+ exports.batchRelation = objectDsl.batchRelation
108
+ exports.composeGraph = objectDsl.composeGraph
105
109
  exports.dimension = dimension
106
110
  exports.measure = measure
107
111
  exports.asc = objectDsl.asc