Skip to content

Commit 24dce24

Browse files
authored
EdgeQL Hydration (#3068)
1 parent 2579276 commit 24dce24

File tree

9 files changed

+256
-27
lines changed

9 files changed

+256
-27
lines changed

dbschema/common.esdl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@ module default {
1818
);
1919

2020
scalar type RichText extending json;
21+
22+
# A fake function to produce valid EdgeQL syntax.
23+
# This will be reflected to dynamically inject portable shapes in our EdgeQL queries.
24+
function hydrate(typeName: str, scopedValue: json) -> str
25+
using (typeName);
2126
}

dbschema/migrations/00052.edgeql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE MIGRATION m1rqfvf4ah2ev4ncoxa6l7x5u6h6tssd6r7cbneshimgxzbtdzdppq
2+
ONTO m1tgd6yl63z2bp7ahtbi7sidb5gfl7mjs47ooqfbnuleffpap3auua
3+
{
4+
CREATE FUNCTION default::hydrate(typeName: std::str, scopedValue: std::json) -> std::str USING (typeName);
5+
};

src/core/edgedb/edgedb.service.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { $, Executor } from 'edgedb';
44
import { retry, RetryOptions } from '~/common/retry';
55
import { TypedEdgeQL } from './edgeql';
66
import { ExclusivityViolationError } from './exclusivity-violation.error';
7-
import { InlineQueryCardinalityMap } from './generated-client/inline-queries';
7+
import { InlineQueryRuntimeMap } from './generated-client/inline-queries';
88
import { OptionsContext, OptionsFn } from './options.context';
99
import { Client } from './reexports';
1010
import { TransactionContext } from './transaction.context';
@@ -61,13 +61,14 @@ export class EdgeDB {
6161
async run(query: any, args?: any) {
6262
try {
6363
if (query instanceof TypedEdgeQL) {
64-
const cardinality = InlineQueryCardinalityMap.get(query.query);
65-
if (!cardinality) {
64+
const found = InlineQueryRuntimeMap.get(query.query);
65+
if (!found) {
6666
throw new Error(`Query was not found from inline query generation`);
6767
}
68-
const exeMethod = cardinalityToExecutorMethod[cardinality];
68+
const exeMethod = cardinalityToExecutorMethod[found.cardinality];
6969

70-
return await this.executor.current[exeMethod](query.query, args);
70+
// eslint-disable-next-line @typescript-eslint/return-await
71+
return await this.executor.current[exeMethod](found.query, args);
7172
}
7273

7374
if (query.run) {
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { cleanSplit, mapEntries } from '@seedcompany/common';
2+
import { adapter } from 'edgedb';
3+
import { $ as $$ } from 'execa';
4+
import { readFile } from 'fs/promises';
5+
import { Directory, Node, SyntaxKind } from 'ts-morph';
6+
import { hydratorsNeeded, injectHydrators } from './inject-hydrators';
7+
import { GeneratorParams, toFqn } from './util';
8+
9+
interface Hydrator {
10+
fqn: string;
11+
type: string;
12+
query: string;
13+
fields: string;
14+
source: string;
15+
dependencies: Set<string>;
16+
}
17+
export type HydratorMap = ReadonlyMap<string, Hydrator>;
18+
19+
export async function findHydrationShapes({ root }: GeneratorParams) {
20+
const queries = await Promise.all([
21+
findInQueryFiles(root),
22+
findInInlineQueries(root),
23+
]);
24+
25+
const unsorted = mapEntries(queries.flat(), ({ query, source }, { SKIP }) => {
26+
const matches = query.match(RE_SELECT_NAME_AND_FIELDS);
27+
if (!matches) {
28+
return SKIP;
29+
}
30+
const [_, type] = matches;
31+
const fqn = toFqn(type);
32+
const dependencies = hydratorsNeeded(query);
33+
const hydrator: Hydrator = {
34+
fqn,
35+
type,
36+
query,
37+
source,
38+
dependencies,
39+
fields: '',
40+
};
41+
return [fqn, hydrator];
42+
}).asMap;
43+
44+
const hydrators = topoSort(unsorted);
45+
46+
for (const hydrator of hydrators.values()) {
47+
hydrator.query = injectHydrators(hydrator.query, hydrators);
48+
hydrator.fields = hydrator.query.match(RE_SELECT_NAME_AND_FIELDS)![2];
49+
}
50+
51+
return hydrators;
52+
}
53+
54+
function topoSort(map: HydratorMap): HydratorMap {
55+
const sorted = new Map<string, Hydrator>();
56+
const visiting = new Set<string>();
57+
58+
const visit = (hydrator: Hydrator) => {
59+
if (visiting.has(hydrator.fqn)) {
60+
const last = Array.from(visiting).at(-1)!;
61+
throw new Error(
62+
`Circular dependency involving ${hydrator.type} and ${last}`,
63+
);
64+
}
65+
visiting.add(hydrator.fqn);
66+
for (const dep of hydrator.dependencies) {
67+
const depHydrator = map.get(dep);
68+
if (!depHydrator) {
69+
throw new Error(`Hydrator ${dep} referenced but not defined`);
70+
}
71+
visit(depHydrator);
72+
}
73+
sorted.set(hydrator.fqn, hydrator);
74+
visiting.delete(hydrator.fqn);
75+
};
76+
77+
for (const type of map.values()) {
78+
visit(type);
79+
}
80+
81+
return sorted;
82+
}
83+
84+
async function findInQueryFiles(root: Directory) {
85+
const grepForShortList = await $$({
86+
reject: false,
87+
cwd: root.getPath(),
88+
})`find src -type f -name hydrate*.edgeql`;
89+
const shortList = grepForShortList.stdout
90+
? grepForShortList.stdout.split('\n')
91+
: [];
92+
const all = await Promise.all(
93+
shortList.map(async (path) => {
94+
const contents = await readFile(path, 'utf8');
95+
return { contents, source: './' + path };
96+
}),
97+
);
98+
return all.flatMap(({ contents, source }) =>
99+
cleanSplit(contents, ';').map((query) => ({ query, source })),
100+
);
101+
}
102+
103+
async function findInInlineQueries(root: Directory) {
104+
const grepForShortList = await $$({
105+
reject: false,
106+
cwd: root.getPath(),
107+
})`grep -lRE ${` hydrate\\w+ = edgeql`} src --exclude-dir=src/core/edgedb`;
108+
const shortList = grepForShortList.stdout
109+
? root.addSourceFilesAtPaths(grepForShortList.stdout.split('\n'))
110+
: [];
111+
return (
112+
shortList.flatMap((file) =>
113+
file.getDescendantsOfKind(SyntaxKind.CallExpression).flatMap((call) => {
114+
if (call.getExpression().getText() !== 'edgeql') {
115+
return [];
116+
}
117+
const args = call.getArguments();
118+
119+
if (
120+
args.length > 1 ||
121+
(!Node.isStringLiteral(args[0]) &&
122+
!Node.isNoSubstitutionTemplateLiteral(args[0]))
123+
) {
124+
return [];
125+
}
126+
// Too hard to find parent types that have name
127+
if (!(call as any).getParent()?.getName()?.includes('hydrate')) {
128+
return [];
129+
}
130+
131+
const path = adapter.path.posix.relative(
132+
root.getPath(),
133+
call.getSourceFile().getFilePath(),
134+
);
135+
const lineNumber = call.getStartLineNumber();
136+
const source = `./${path}:${lineNumber}`;
137+
138+
const query = args[0].getText().slice(1, -1);
139+
return { query, source };
140+
}),
141+
) ?? []
142+
);
143+
}
144+
145+
const RE_SELECT_NAME_AND_FIELDS = /select ([\w:]+) \{((?:.|\n)+)}/i;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { HydratorMap } from './find-hydration-shapes';
2+
import { toFqn } from './util';
3+
4+
export const injectHydrators = (query: string, map: HydratorMap) => {
5+
return query
6+
.replaceAll(RE_SELECT, (_, type: string) => {
7+
const hydration = map.get(toFqn(type));
8+
if (!hydration) {
9+
return _;
10+
}
11+
return `select ${type} {${hydration.fields}}`;
12+
})
13+
.replaceAll(RE_HYDRATE_CALL, (fakeFn) => {
14+
const matches = fakeFn.match(RE_HYDRATE_EXTRACT);
15+
if (!matches) {
16+
return fakeFn;
17+
}
18+
const [, key, variable] = matches;
19+
const hydration = map.get(toFqn(key));
20+
if (!hydration) {
21+
return fakeFn;
22+
}
23+
const injected = `(select ${variable} {${hydration.fields.replaceAll(
24+
RegExp('(?<!\\sDETACHED\\s)' + hydration.type, 'gi'),
25+
variable,
26+
)}})`;
27+
return injected;
28+
});
29+
};
30+
31+
export const hydratorsNeeded = (query: string) => {
32+
const needed = new Set<string>();
33+
query
34+
.replaceAll(RE_SELECT, (_, type: string) => {
35+
needed.add(toFqn(type));
36+
return _;
37+
})
38+
.replaceAll(RE_HYDRATE_CALL, (fakeFn) => {
39+
const matches = fakeFn.match(RE_HYDRATE_EXTRACT);
40+
if (!matches) {
41+
return fakeFn;
42+
}
43+
needed.add(toFqn(matches[1]));
44+
return fakeFn;
45+
});
46+
return needed;
47+
};
48+
49+
const RE_SELECT = /select\s+([\w:]+)\s*\{}/gi;
50+
const RE_HYDRATE_CALL = /hydrate\(.+\)/g;
51+
const RE_HYDRATE_EXTRACT = /hydrate\(['"]([\w:]+)['"],\s*<json>(.+)\)/;

src/core/edgedb/generator/inline-queries.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import { stripIndent } from 'common-tags';
2-
import { $, adapter, Client } from 'edgedb';
2+
import { $, adapter } from 'edgedb';
33
import { Cardinality } from 'edgedb/dist/ifaces.js';
44
import { $ as $$ } from 'execa';
5-
import { Directory, Node, SyntaxKind, VariableDeclarationKind } from 'ts-morph';
5+
import { Node, SyntaxKind, VariableDeclarationKind } from 'ts-morph';
6+
import { injectHydrators } from './inject-hydrators';
67
import { customScalars } from './scalars';
7-
import { addCustomScalarImports } from './util';
8+
import { addCustomScalarImports, GeneratorParams } from './util';
89

910
export async function generateInlineQueries({
1011
client,
1112
root,
12-
}: {
13-
client: Client;
14-
root: Directory;
15-
}) {
13+
hydrators,
14+
}: GeneratorParams) {
1615
console.log('Generating queries for edgeql() calls...');
1716

1817
const grepForShortList = await $$({
@@ -59,14 +58,17 @@ export async function generateInlineQueries({
5958
`import type { TypedEdgeQL } from '../edgeql';`,
6059
{ overwrite: true },
6160
);
62-
const queryMap = inlineQueriesFile.addInterface({
61+
const queryMapType = inlineQueriesFile.addInterface({
6362
name: 'InlineQueryMap',
6463
isExported: true,
6564
});
6665

6766
const imports = new Set<string>();
6867
const seen = new Set<string>();
69-
const cardinalityMap = new Map<string, $.Cardinality>();
68+
const queryMap = new Map<
69+
string,
70+
{ query: string; cardinality: $.Cardinality }
71+
>();
7072
for (const { query, call } of queries) {
7173
// Prevent duplicate keys in QueryMap in the off chance that two queries are identical
7274
if (seen.has(query)) {
@@ -84,32 +86,34 @@ export async function generateInlineQueries({
8486
let types;
8587
let error;
8688
try {
87-
types = await $.analyzeQuery(client, query);
89+
const injectedQuery = injectHydrators(query, hydrators);
90+
91+
types = await $.analyzeQuery(client, injectedQuery);
8892
console.log(` ${source}`);
8993
} catch (err) {
9094
error = err as Error;
9195
console.log(`Error in query '${source}': ${String(err)}`);
9296
}
9397

9498
if (types) {
95-
// Save cardinality for use at runtime.
96-
cardinalityMap.set(
97-
stripIndent(query),
98-
cardinalityMapping[types.cardinality],
99-
);
99+
// Save cardinality & hydrated query for use at runtime.
100+
queryMap.set(stripIndent(query), {
101+
query: injectHydrators(query, hydrators),
102+
cardinality: cardinalityMapping[types.cardinality],
103+
});
100104
// Add imports to the used imports list
101105
[...types.imports].forEach((i) => imports.add(i));
102106
}
103107

104-
queryMap.addProperty({
108+
queryMapType.addProperty({
105109
name: `[\`${query}\`]`,
106110
type: types
107111
? `TypedEdgeQL<${types.args}, ${types.result}>`
108112
: error
109113
? `{ ${error.name}: \`${error.message.trim()}\` }`
110114
: 'unknown',
111115
leadingTrivia:
112-
(queryMap.getProperties().length > 0 ? '\n' : '') +
116+
(queryMapType.getProperties().length > 0 ? '\n' : '') +
113117
`/** {@link import('${path}')} L${lineNumber} */\n`,
114118
});
115119
}
@@ -126,14 +130,14 @@ export async function generateInlineQueries({
126130
moduleSpecifier: 'edgedb',
127131
});
128132

129-
const cardinalitiesAsStr = JSON.stringify([...cardinalityMap], null, 2);
133+
const queryMapAsStr = JSON.stringify([...queryMap], null, 2);
130134
inlineQueriesFile.addVariableStatement({
131135
isExported: true,
132136
declarationKind: VariableDeclarationKind.Const,
133137
declarations: [
134138
{
135-
name: 'InlineQueryCardinalityMap',
136-
initializer: `new Map<string, \`\${$.Cardinality}\`>(${cardinalitiesAsStr})`,
139+
name: 'InlineQueryRuntimeMap',
140+
initializer: `new Map<string, { query: string, cardinality: \`\${$.Cardinality}\` }>(${queryMapAsStr})`,
137141
},
138142
],
139143
});

src/core/edgedb/generator/query-files.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { $, adapter } from 'edgedb';
77
import { Directory } from 'ts-morph';
88
import { ScalarInfo } from '../codecs';
9+
import { injectHydrators } from './inject-hydrators';
910
import { customScalars } from './scalars';
1011
import { addCustomScalarImports, GeneratorParams } from './util';
1112

@@ -21,13 +22,16 @@ export async function generateQueryFiles(params: GeneratorParams) {
2122
}
2223

2324
const generateFilesForQuery =
24-
({ client, root }: GeneratorParams) =>
25+
({ client, root, hydrators }: GeneratorParams) =>
2526
async (path: string) => {
2627
const prettyPath = './' + adapter.path.posix.relative(root.getPath(), path);
2728
try {
2829
const query = await adapter.readFileUtf8(path);
2930
if (!query) return;
30-
const types = await $.analyzeQuery(client, query);
31+
32+
const injectedQuery = injectHydrators(query, hydrators);
33+
34+
const types = await $.analyzeQuery(client, injectedQuery);
3135
const [{ imports, contents }] = generateFiles({
3236
target: 'ts',
3337
path,

0 commit comments

Comments
 (0)