@syncular/typegen 0.2.1 → 0.3.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 +25 -10
- package/dist/cli.js +155 -4
- package/dist/emit-dart.js +3 -2
- package/dist/emit-kotlin.js +3 -2
- package/dist/emit-queries-dart.js +87 -15
- package/dist/emit-queries-kotlin.js +89 -15
- package/dist/emit-queries-swift.js +92 -14
- package/dist/emit-queries.js +131 -22
- package/dist/emit-swift.js +3 -2
- package/dist/emit.d.ts +2 -1
- package/dist/emit.js +13 -27
- package/dist/fmt.d.ts +3 -0
- package/dist/fmt.js +356 -0
- package/dist/generate.d.ts +16 -6
- package/dist/generate.js +34 -9
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lower.d.ts +54 -0
- package/dist/lower.js +283 -0
- package/dist/lsp.d.ts +20 -0
- package/dist/lsp.js +376 -0
- package/dist/manifest.d.ts +11 -0
- package/dist/manifest.js +20 -0
- package/dist/naming.d.ts +22 -0
- package/dist/naming.js +240 -0
- package/dist/query-ir.d.ts +14 -0
- package/dist/query-ir.js +69 -0
- package/dist/query.d.ts +102 -6
- package/dist/query.js +126 -34
- package/dist/syql.d.ts +83 -0
- package/dist/syql.js +945 -0
- package/package.json +2 -2
- package/src/cli.ts +155 -4
- package/src/emit-dart.ts +3 -2
- package/src/emit-kotlin.ts +3 -2
- package/src/emit-queries-dart.ts +109 -16
- package/src/emit-queries-kotlin.ts +111 -18
- package/src/emit-queries-swift.ts +106 -17
- package/src/emit-queries.ts +179 -28
- package/src/emit-swift.ts +3 -2
- package/src/emit.ts +18 -28
- package/src/fmt.ts +351 -0
- package/src/generate.ts +54 -11
- package/src/index.ts +6 -0
- package/src/lower.ts +331 -0
- package/src/lsp.ts +445 -0
- package/src/manifest.ts +38 -0
- package/src/naming.ts +271 -0
- package/src/query-ir.ts +82 -0
- package/src/query.ts +241 -34
- package/src/syql.ts +1171 -0
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* bytes decode). Header carries the IR hash for byte-exact `--check`.
|
|
14
14
|
*/
|
|
15
15
|
import type { IrColumnType } from './ir';
|
|
16
|
-
import
|
|
16
|
+
import { snakeToCamel } from './naming';
|
|
17
|
+
import type { AnalyzedQuery, QueryColumn, QueryParam } from './query';
|
|
17
18
|
|
|
18
19
|
const SWIFT_TYPE: Readonly<Record<IrColumnType, string>> = {
|
|
19
20
|
string: 'String',
|
|
@@ -34,9 +35,9 @@ function pascalCase(name: string): string {
|
|
|
34
35
|
.join('');
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
37
39
|
function camelCase(name: string): string {
|
|
38
|
-
|
|
39
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
40
|
+
return snakeToCamel(name);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/** The query function/type name: already camelCase; PascalCase for the type. */
|
|
@@ -49,7 +50,7 @@ function quote(value: string): string {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function rowAccessor(column: QueryColumn): string {
|
|
52
|
-
const key = `row[${quote(column.
|
|
53
|
+
const key = `row[${quote(column.langName)}]`;
|
|
53
54
|
switch (column.type) {
|
|
54
55
|
case 'string':
|
|
55
56
|
case 'json':
|
|
@@ -82,6 +83,32 @@ function paramValue(type: IrColumnType, name: string): string {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/** Bind for a §4 OPTIONAL param: `nil` rides as JSON null (the §7
|
|
87
|
+
* neutralization guards make it a no-op). */
|
|
88
|
+
function optionalParamValue(type: IrColumnType, name: string): string {
|
|
89
|
+
switch (type) {
|
|
90
|
+
case 'integer':
|
|
91
|
+
return `${name}.map { JSONValue.number(Double($0)) } ?? .null`;
|
|
92
|
+
case 'float':
|
|
93
|
+
return `${name}.map { JSONValue.number($0) } ?? .null`;
|
|
94
|
+
case 'boolean':
|
|
95
|
+
return `${name}.map { JSONValue.bool($0) } ?? .null`;
|
|
96
|
+
case 'bytes':
|
|
97
|
+
case 'crdt':
|
|
98
|
+
return `${name}.map { SyncularSchemaQueryBind.bytes($0) } ?? .null`;
|
|
99
|
+
default:
|
|
100
|
+
return `${name}.map { JSONValue.string($0) } ?? .null`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isOptionalParam(query: AnalyzedQuery, p: QueryParam): boolean {
|
|
105
|
+
return (
|
|
106
|
+
p.optional === true ||
|
|
107
|
+
p.flag === true ||
|
|
108
|
+
(query.limit !== undefined && p.name === 'limit')
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
function emitRowStruct(query: AnalyzedQuery): string[] {
|
|
86
113
|
const Row = `${typeName(query.name)}Row`;
|
|
87
114
|
const lines: string[] = [];
|
|
@@ -90,13 +117,13 @@ function emitRowStruct(query: AnalyzedQuery): string[] {
|
|
|
90
117
|
for (const column of query.columns) {
|
|
91
118
|
const opt = column.nullable ? '?' : '';
|
|
92
119
|
lines.push(
|
|
93
|
-
` public let ${camelCase(column.
|
|
120
|
+
` public let ${camelCase(column.langName)}: ${SWIFT_TYPE[column.type]}${opt}`,
|
|
94
121
|
);
|
|
95
122
|
}
|
|
96
123
|
lines.push('');
|
|
97
124
|
lines.push(' public init?(row: [String: JSONValue]) {');
|
|
98
125
|
for (const column of query.columns) {
|
|
99
|
-
const name = camelCase(column.
|
|
126
|
+
const name = camelCase(column.langName);
|
|
100
127
|
const accessor = rowAccessor(column);
|
|
101
128
|
if (column.nullable) {
|
|
102
129
|
lines.push(` self.${name} = ${accessor}`);
|
|
@@ -110,38 +137,86 @@ function emitRowStruct(query: AnalyzedQuery): string[] {
|
|
|
110
137
|
return lines;
|
|
111
138
|
}
|
|
112
139
|
|
|
140
|
+
/** Per-query orderBy allowlist enum (rawValue = the checked SQL column). */
|
|
141
|
+
function emitOrderByEnum(query: AnalyzedQuery): string[] {
|
|
142
|
+
if (query.orderBy === undefined) return [];
|
|
143
|
+
const lines: string[] = [];
|
|
144
|
+
lines.push(
|
|
145
|
+
`/// §6 orderBy allowlist for ${query.name} — checked at generate time.`,
|
|
146
|
+
);
|
|
147
|
+
lines.push(`public enum ${typeName(query.name)}OrderBy: String, Sendable {`);
|
|
148
|
+
for (const col of query.orderBy.allowed) {
|
|
149
|
+
lines.push(` case ${camelCase(col.langName)} = ${quote(col.name)}`);
|
|
150
|
+
}
|
|
151
|
+
lines.push('}');
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
|
|
113
155
|
function emitRunner(query: AnalyzedQuery): string[] {
|
|
114
156
|
const Row = `${typeName(query.name)}Row`;
|
|
115
157
|
const lines: string[] = [];
|
|
116
158
|
lines.push(
|
|
117
159
|
` public static let ${query.name}Tables = [${query.tables.map(quote).join(', ')}]`,
|
|
118
160
|
);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
if (query.orderBy !== undefined) {
|
|
162
|
+
lines.push(
|
|
163
|
+
` private static let ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`,
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
lines.push(
|
|
167
|
+
` private static let ${query.name}Sql = ${quote(query.positionalSql)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
122
170
|
lines.push('');
|
|
123
171
|
lines.push(` /// Run the ${query.name} named query (SELECT-only).`);
|
|
124
|
-
const args =
|
|
125
|
-
|
|
126
|
-
.
|
|
172
|
+
const args: string[] = [];
|
|
173
|
+
for (const p of query.params) {
|
|
174
|
+
const name = camelCase(p.langName);
|
|
175
|
+
if (isOptionalParam(query, p)) {
|
|
176
|
+
args.push(`${name}: ${SWIFT_TYPE[p.type]}? = nil`);
|
|
177
|
+
} else {
|
|
178
|
+
args.push(`${name}: ${SWIFT_TYPE[p.type]}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (query.orderBy !== undefined) {
|
|
182
|
+
const defaultCase = camelCase(
|
|
183
|
+
query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
|
|
184
|
+
?.langName ?? query.orderBy.defaultColumn,
|
|
185
|
+
);
|
|
186
|
+
args.push(`orderBy: ${typeName(query.name)}OrderBy = .${defaultCase}`);
|
|
187
|
+
args.push(`dir: SyncularQueryDir = .${query.orderBy.defaultDir}`);
|
|
188
|
+
}
|
|
127
189
|
const signature =
|
|
128
|
-
|
|
129
|
-
? `client: SyncularClient, ${args}`
|
|
190
|
+
args.length > 0
|
|
191
|
+
? `client: SyncularClient, ${args.join(', ')}`
|
|
130
192
|
: 'client: SyncularClient';
|
|
131
193
|
lines.push(
|
|
132
194
|
` public static func ${query.name}(${signature}) throws -> [${Row}] {`,
|
|
133
195
|
);
|
|
196
|
+
const sqlExpr =
|
|
197
|
+
query.orderBy !== undefined
|
|
198
|
+
? `${query.name}SqlBase + " order by " + orderBy.rawValue + " " + dir.rawValue${query.positionalLimitTail !== undefined ? ` + ${quote(query.positionalLimitTail)}` : ''}`
|
|
199
|
+
: `${query.name}Sql`;
|
|
200
|
+
if (query.orderBy !== undefined) {
|
|
201
|
+
lines.push(` let sql = ${sqlExpr}`);
|
|
202
|
+
}
|
|
203
|
+
const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
|
|
134
204
|
if (query.params.length > 0) {
|
|
135
205
|
const binds = query.params
|
|
136
|
-
.map((p) =>
|
|
206
|
+
.map((p) => {
|
|
207
|
+
const name = camelCase(p.langName);
|
|
208
|
+
return isOptionalParam(query, p)
|
|
209
|
+
? optionalParamValue(p.type, name)
|
|
210
|
+
: paramValue(p.type, name);
|
|
211
|
+
})
|
|
137
212
|
.join(', ');
|
|
138
213
|
lines.push(` let params: [JSONValue] = [${binds}]`);
|
|
139
214
|
lines.push(
|
|
140
|
-
` return try client.query(${
|
|
215
|
+
` return try client.query(${sqlRef}, params: params).compactMap { row in`,
|
|
141
216
|
);
|
|
142
217
|
} else {
|
|
143
218
|
lines.push(
|
|
144
|
-
` return try client.query(${
|
|
219
|
+
` return try client.query(${sqlRef}).compactMap { row in`,
|
|
145
220
|
);
|
|
146
221
|
}
|
|
147
222
|
lines.push(
|
|
@@ -183,8 +258,22 @@ export function emitQueriesSwiftModule(
|
|
|
183
258
|
].join('\n'),
|
|
184
259
|
);
|
|
185
260
|
|
|
261
|
+
if (queries.some((q) => q.orderBy !== undefined)) {
|
|
262
|
+
parts.push(
|
|
263
|
+
[
|
|
264
|
+
'/// §6 orderBy direction (shared by every orderBy-knob query).',
|
|
265
|
+
'public enum SyncularQueryDir: String, Sendable {',
|
|
266
|
+
' case asc = "asc"',
|
|
267
|
+
' case desc = "desc"',
|
|
268
|
+
'}',
|
|
269
|
+
].join('\n'),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
186
273
|
for (const query of queries) {
|
|
187
274
|
parts.push(emitRowStruct(query).join('\n'));
|
|
275
|
+
const orderByEnum = emitOrderByEnum(query);
|
|
276
|
+
if (orderByEnum.length > 0) parts.push(orderByEnum.join('\n'));
|
|
188
277
|
}
|
|
189
278
|
|
|
190
279
|
const enumLines: string[] = [];
|
package/src/emit-queries.ts
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - `QRow` — the projection row interface (its OWN type per query — the
|
|
6
6
|
* drift-kill: the row shape is exactly what the SELECT returns),
|
|
7
|
-
* - `QParams` — the typed params object (when the query has params
|
|
8
|
-
*
|
|
7
|
+
* - `QParams` — the typed params object (when the query has params or
|
|
8
|
+
* knobs); §4 optional params are optional keys (`status?: string | null`),
|
|
9
|
+
* §6 knobs add `orderBy?/dir?/limit?`,
|
|
10
|
+
* - `qTables` — the readonly table-dependency set (feeds useRawSql `{tables}`
|
|
9
11
|
* for EXACT invalidation),
|
|
10
12
|
* - `q(client, params?): Promise<QRow[]>` — runs the query over the wrapper's
|
|
11
13
|
* positional `query(sql, params[])` surface, reordering the named params
|
|
12
|
-
* object into the positional array the wire expects
|
|
14
|
+
* object into the positional array the wire expects (optionals bind NULL —
|
|
15
|
+
* the §7 neutralization guards make an absent param a no-op),
|
|
16
|
+
* - for an orderBy knob: a baked column map + a compose function — user
|
|
17
|
+
* input only ever SELECTS from the generate-time-checked allowlist (I2);
|
|
18
|
+
* it never becomes SQL text.
|
|
13
19
|
*
|
|
14
20
|
* A tiny structural `QueryClient` interface (just `query(sql, params?)`) keeps
|
|
15
21
|
* the module import-free — it structurally accepts `SyncClientLike` /
|
|
@@ -17,7 +23,7 @@
|
|
|
17
23
|
* `--check` gates freshness byte-exactly, like every other emitter.
|
|
18
24
|
*/
|
|
19
25
|
import type { IrColumnType } from './ir';
|
|
20
|
-
import type { AnalyzedQuery } from './query';
|
|
26
|
+
import type { AnalyzedQuery, QueryParam } from './query';
|
|
21
27
|
|
|
22
28
|
const TS_TYPE: Readonly<Record<IrColumnType, string>> = {
|
|
23
29
|
string: 'string',
|
|
@@ -46,81 +52,223 @@ function propertyKey(name: string): string {
|
|
|
46
52
|
/** A named-query param value is the SqlValue subset its type maps to. */
|
|
47
53
|
const PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = TS_TYPE;
|
|
48
54
|
|
|
55
|
+
function isOptional(param: QueryParam): boolean {
|
|
56
|
+
return param.optional === true || param.flag === true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The positional bind expression for one param. `access` is `params` or
|
|
60
|
+
* `params?` depending on whether the params object itself may be absent. */
|
|
61
|
+
function bindExpr(
|
|
62
|
+
query: AnalyzedQuery,
|
|
63
|
+
param: QueryParam,
|
|
64
|
+
access: string,
|
|
65
|
+
): string {
|
|
66
|
+
if (query.limit !== undefined && param.name === 'limit') {
|
|
67
|
+
// The default + clamp live IN the SQL (`min(coalesce(?, d), m)`).
|
|
68
|
+
return `${access}.limit ?? null`;
|
|
69
|
+
}
|
|
70
|
+
const key = propertyKey(param.langName);
|
|
71
|
+
return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
function emitQuery(query: AnalyzedQuery): string {
|
|
50
75
|
const Row = `${pascalCase(query.name)}Row`;
|
|
51
76
|
const Params = `${pascalCase(query.name)}Params`;
|
|
52
77
|
const lines: string[] = [];
|
|
53
78
|
|
|
54
|
-
// Row interface
|
|
79
|
+
// Row interface — keyed by the language-facing names, which ARE the
|
|
80
|
+
// runtime result keys (§5 projection lowering aliases them in SQL).
|
|
55
81
|
lines.push(
|
|
56
82
|
`/** One row of the ${quote(query.name)} query (its projection). */`,
|
|
57
83
|
);
|
|
58
84
|
lines.push(`export interface ${Row} {`);
|
|
59
85
|
for (const column of query.columns) {
|
|
60
86
|
lines.push(
|
|
61
|
-
` ${propertyKey(column.
|
|
87
|
+
` ${propertyKey(column.langName)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`,
|
|
62
88
|
);
|
|
63
89
|
}
|
|
64
90
|
lines.push('}');
|
|
65
91
|
lines.push('');
|
|
66
92
|
|
|
67
|
-
|
|
68
|
-
const
|
|
93
|
+
const hasParams = query.params.length > 0 || query.orderBy !== undefined;
|
|
94
|
+
const requiresParams = query.params.some(
|
|
95
|
+
(p) => !isOptional(p) && !(query.limit !== undefined && p.name === 'limit'),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Params interface (params and/or knob keys).
|
|
69
99
|
if (hasParams) {
|
|
70
100
|
lines.push(`/** Named parameters for ${quote(query.name)}. */`);
|
|
71
101
|
lines.push(`export interface ${Params} {`);
|
|
72
102
|
for (const param of query.params) {
|
|
73
|
-
|
|
103
|
+
if (query.limit !== undefined && param.name === 'limit') continue;
|
|
104
|
+
const opt = isOptional(param);
|
|
105
|
+
lines.push(
|
|
106
|
+
` ${propertyKey(param.langName)}${opt ? '?' : ''}: ${PARAM_TS_TYPE[param.type]}${opt ? ' | null' : ''};`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (query.orderBy !== undefined) {
|
|
110
|
+
const keys = query.orderBy.allowed
|
|
111
|
+
.map((c) => quote(c.langName))
|
|
112
|
+
.join(' | ');
|
|
113
|
+
lines.push(
|
|
114
|
+
` /** §6 orderBy knob — a generate-time-checked allowlist. */`,
|
|
115
|
+
);
|
|
116
|
+
lines.push(` orderBy?: ${keys};`);
|
|
117
|
+
lines.push(` dir?: 'asc' | 'desc';`);
|
|
118
|
+
}
|
|
119
|
+
if (query.limit !== undefined) {
|
|
120
|
+
const clamp =
|
|
121
|
+
query.limit.max !== undefined ? ` (clamped to ${query.limit.max})` : '';
|
|
122
|
+
lines.push(
|
|
123
|
+
` /** §6 limit knob — binds as a value${clamp}; default ${query.limit.default ?? query.limit.max}. */`,
|
|
124
|
+
);
|
|
125
|
+
lines.push(' limit?: number;');
|
|
74
126
|
}
|
|
75
127
|
lines.push('}');
|
|
76
128
|
lines.push('');
|
|
77
129
|
}
|
|
78
130
|
|
|
79
|
-
// Tables dependency set (for
|
|
131
|
+
// Tables dependency set (for useRawSql {tables} / useQuery).
|
|
80
132
|
lines.push(
|
|
81
|
-
`/** Tables ${quote(query.name)} reads — the exact
|
|
133
|
+
`/** Tables ${quote(query.name)} reads — the exact useRawSql \`{tables}\` set. */`,
|
|
82
134
|
);
|
|
83
135
|
lines.push(
|
|
84
136
|
`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`,
|
|
85
137
|
);
|
|
86
138
|
lines.push('');
|
|
87
139
|
|
|
88
|
-
//
|
|
89
|
-
|
|
140
|
+
// SQL constants: the static default statement, plus (orderBy knob) the
|
|
141
|
+
// baked base + column map + compose function.
|
|
90
142
|
const sqlConst = `${query.name}Sql`;
|
|
143
|
+
lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
|
|
144
|
+
const composeFn = `${query.name}ComposeSql`;
|
|
145
|
+
if (query.orderBy !== undefined) {
|
|
146
|
+
const base = `${query.name}SqlBase`;
|
|
147
|
+
const colsConst = `${query.name}OrderColumns`;
|
|
148
|
+
const defaultLang =
|
|
149
|
+
query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
|
|
150
|
+
?.langName ?? query.orderBy.defaultColumn;
|
|
151
|
+
lines.push(`const ${base} = ${quote(query.positionalSqlBase ?? '')};`);
|
|
152
|
+
lines.push(
|
|
153
|
+
`const ${colsConst} = { ${query.orderBy.allowed
|
|
154
|
+
.map((c) => `${propertyKey(c.langName)}: ${quote(c.name)}`)
|
|
155
|
+
.join(', ')} } as const;`,
|
|
156
|
+
);
|
|
157
|
+
lines.push(`function ${composeFn}(params?: ${Params}): string {`);
|
|
158
|
+
lines.push(
|
|
159
|
+
` const column = ${colsConst}[params?.orderBy ?? ${quote(defaultLang)}] ?? ${quote(query.orderBy.defaultColumn)};`,
|
|
160
|
+
);
|
|
161
|
+
lines.push(
|
|
162
|
+
` const dir = (params?.dir ?? ${quote(query.orderBy.defaultDir)}) === 'desc' ? 'desc' : 'asc';`,
|
|
163
|
+
);
|
|
164
|
+
lines.push(
|
|
165
|
+
` return \`\${${base}} order by \${column} \${dir}${query.positionalLimitTail ?? ''}\`;`,
|
|
166
|
+
);
|
|
167
|
+
lines.push('}');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Positional bind list.
|
|
171
|
+
const access = requiresParams ? 'params' : 'params?';
|
|
91
172
|
const positional = hasParams
|
|
92
|
-
? `[${query.params.map((p) =>
|
|
173
|
+
? `[${query.params.map((p) => bindExpr(query, p, access)).join(', ')}]`
|
|
93
174
|
: '[]';
|
|
94
|
-
|
|
175
|
+
|
|
176
|
+
// §7 variant backend: one statement per provided-combination, selected by
|
|
177
|
+
// a bitmask over the optional groups (bit i = group i provided/true). The
|
|
178
|
+
// neutralization statement above stays the canonical/default form.
|
|
179
|
+
const selectFn = `${query.name}SelectVariant`;
|
|
180
|
+
const paramArgDecl = requiresParams
|
|
181
|
+
? `params: ${Params}`
|
|
182
|
+
: `params?: ${Params}`;
|
|
183
|
+
if (query.variants !== undefined && query.variantGroups !== undefined) {
|
|
184
|
+
const variantsConst = `${query.name}Variants`;
|
|
185
|
+
lines.push(
|
|
186
|
+
`const ${variantsConst}: { sql: string; bind: (${paramArgDecl}) => QueryValue[] }[] = [`,
|
|
187
|
+
);
|
|
188
|
+
for (const variant of query.variants) {
|
|
189
|
+
const binds = variant.params
|
|
190
|
+
.map((name) => {
|
|
191
|
+
const param = query.params.find((p) => p.name === name);
|
|
192
|
+
if (param === undefined) throw new Error(`unknown param ${name}`);
|
|
193
|
+
return bindExpr(query, param, access);
|
|
194
|
+
})
|
|
195
|
+
.join(', ');
|
|
196
|
+
lines.push(` // [${variant.when.join(' + ') || 'no optional filters'}]`);
|
|
197
|
+
lines.push(
|
|
198
|
+
` { sql: ${quote(variant.positionalSql)}, bind: (${requiresParams ? 'params' : 'params?'}) => [${binds}] },`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
lines.push('];');
|
|
202
|
+
lines.push(
|
|
203
|
+
`function ${selectFn}(${paramArgDecl}): { sql: string; bind: QueryValue[] } {`,
|
|
204
|
+
);
|
|
205
|
+
lines.push(' let mask = 0;');
|
|
206
|
+
query.variantGroups.forEach((group, index) => {
|
|
207
|
+
const langOf = (name: string): string =>
|
|
208
|
+
query.params.find((p) => p.name === name)?.langName ?? name;
|
|
209
|
+
const condition = group.flag
|
|
210
|
+
? `params?.${propertyKey(langOf(group.params[0] as string))} === true`
|
|
211
|
+
: group.params
|
|
212
|
+
.map(
|
|
213
|
+
(name) =>
|
|
214
|
+
`(params?.${propertyKey(langOf(name))} ?? null) !== null`,
|
|
215
|
+
)
|
|
216
|
+
.join(' && ');
|
|
217
|
+
lines.push(` if (${condition}) mask |= ${1 << index};`);
|
|
218
|
+
});
|
|
219
|
+
lines.push(
|
|
220
|
+
` const variant = ${variantsConst}[mask] as (typeof ${variantsConst})[number];`,
|
|
221
|
+
);
|
|
222
|
+
lines.push(' return { sql: variant.sql, bind: variant.bind(params) };');
|
|
223
|
+
lines.push('}');
|
|
224
|
+
}
|
|
95
225
|
lines.push('');
|
|
226
|
+
|
|
227
|
+
// The runner.
|
|
228
|
+
const paramArg = !hasParams ? '' : paramArgDecl;
|
|
96
229
|
lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
|
|
97
230
|
lines.push(
|
|
98
231
|
`export async function ${query.name}(client: QueryClient${hasParams ? `, ${paramArg}` : ''}): Promise<${Row}[]> {`,
|
|
99
232
|
);
|
|
100
|
-
if (
|
|
101
|
-
lines.push(
|
|
102
|
-
|
|
103
|
-
);
|
|
233
|
+
if (query.variants !== undefined) {
|
|
234
|
+
lines.push(` const variant = ${selectFn}(params);`);
|
|
235
|
+
lines.push(' const rows = await client.query(variant.sql, variant.bind);');
|
|
104
236
|
} else {
|
|
105
|
-
|
|
237
|
+
const sqlExpr =
|
|
238
|
+
query.orderBy !== undefined ? `${composeFn}(params)` : sqlConst;
|
|
239
|
+
if (hasParams) {
|
|
240
|
+
lines.push(
|
|
241
|
+
` const rows = await client.query(${sqlExpr}, ${positional});`,
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
lines.push(` const rows = await client.query(${sqlExpr});`);
|
|
245
|
+
}
|
|
106
246
|
}
|
|
107
247
|
lines.push(` return rows as unknown as ${Row}[];`);
|
|
108
248
|
lines.push('}');
|
|
109
249
|
lines.push('');
|
|
110
250
|
|
|
111
|
-
// A descriptor for react's `
|
|
112
|
-
//
|
|
113
|
-
//
|
|
251
|
+
// A descriptor for react's `useQuery` — the SQL (static, composed from
|
|
252
|
+
// the baked allowlist, or variant-selected), the exact table dependency
|
|
253
|
+
// set, and a `bind(params)` → positional array (always paired with the
|
|
254
|
+
// sqlFor selection). Typed by the query's own Row/Params.
|
|
114
255
|
lines.push(
|
|
115
|
-
`/** Descriptor for \`
|
|
256
|
+
`/** Descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\` — sql + tables + row type. */`,
|
|
116
257
|
);
|
|
117
258
|
const paramsTypeArg = hasParams ? Params : 'undefined';
|
|
118
259
|
lines.push(
|
|
119
260
|
`export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`,
|
|
120
261
|
);
|
|
121
262
|
lines.push(` sql: ${sqlConst},`);
|
|
263
|
+
if (query.variants !== undefined) {
|
|
264
|
+
lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
|
|
265
|
+
} else if (query.orderBy !== undefined) {
|
|
266
|
+
lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
|
|
267
|
+
}
|
|
122
268
|
lines.push(` tables: ${query.name}Tables,`);
|
|
123
|
-
if (
|
|
269
|
+
if (query.variants !== undefined) {
|
|
270
|
+
lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
|
|
271
|
+
} else if (hasParams) {
|
|
124
272
|
lines.push(` bind: (params: ${Params}) => ${positional},`);
|
|
125
273
|
} else {
|
|
126
274
|
lines.push(' bind: () => [],');
|
|
@@ -165,13 +313,16 @@ export function emitQueriesModule(
|
|
|
165
313
|
'',
|
|
166
314
|
'/** A named-query descriptor — sql + its exact table dependency set + a',
|
|
167
315
|
' * `bind(params)` → positional args. Consumed by',
|
|
168
|
-
" * `@syncular/react`'s `
|
|
169
|
-
' * type; `Params` is `undefined` for a param-less query.
|
|
316
|
+
" * `@syncular/react`'s `useQuery`. `Row` is the projection row",
|
|
317
|
+
' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
|
|
318
|
+
' * (present only with an orderBy knob) composes the statement from a',
|
|
319
|
+
' * generate-time-checked column allowlist. */',
|
|
170
320
|
'export interface NamedQuery<Row, Params = undefined> {',
|
|
171
321
|
' readonly sql: string;',
|
|
172
322
|
' readonly tables: readonly string[];',
|
|
173
323
|
' readonly bind: (params: Params) => readonly QueryValue[];',
|
|
174
|
-
'
|
|
324
|
+
' readonly sqlFor?: (params: Params) => string;',
|
|
325
|
+
' /** Phantom — carries the Row type for `useQuery` inference. */',
|
|
175
326
|
' readonly __row?: Row;',
|
|
176
327
|
'}',
|
|
177
328
|
].join('\n'),
|
package/src/emit-swift.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* emitter.
|
|
16
16
|
*/
|
|
17
17
|
import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
|
|
18
|
+
import { snakeToCamel } from './naming';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* §2.4 column type → honest Swift type. `json`/`blob_ref` are the raw
|
|
@@ -49,9 +50,9 @@ function pascalCase(name: string): string {
|
|
|
49
50
|
.join('');
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
52
54
|
function camelCase(name: string): string {
|
|
53
|
-
|
|
54
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
55
|
+
return snakeToCamel(name);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
function quote(value: string): string {
|
package/src/emit.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* byte-exactly.
|
|
14
14
|
*/
|
|
15
15
|
import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
|
|
16
|
+
import { type NamingMode, snakeToCamel } from './naming';
|
|
16
17
|
|
|
17
18
|
const TS_TYPE: Readonly<Record<IrColumnType, string>> = {
|
|
18
19
|
string: 'string',
|
|
@@ -114,15 +115,20 @@ function emitSchema(ir: IrDocument): string {
|
|
|
114
115
|
return lines.join('\n');
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
function emitRowInterfaces(table: IrTable): string {
|
|
118
|
+
function emitRowInterfaces(table: IrTable, naming: NamingMode): string {
|
|
118
119
|
const type = pascalCase(table.name);
|
|
120
|
+
// §5: mutation-input/row keys are the language-facing names; `mutate`
|
|
121
|
+
// normalizes them back through the schema naming map (camel or snake both
|
|
122
|
+
// bind — one map lookup per key).
|
|
123
|
+
const key = (name: string): string =>
|
|
124
|
+
propertyKey(naming === 'camel' ? snakeToCamel(name) : name);
|
|
119
125
|
const lines: string[] = [];
|
|
120
126
|
lines.push(`/** One ${table.name} row (§2.4 column order). */`);
|
|
121
127
|
lines.push(`export interface ${type}Row {`);
|
|
122
128
|
for (const column of table.columns) {
|
|
123
129
|
const ts = appTsType(column);
|
|
124
130
|
lines.push(
|
|
125
|
-
` ${
|
|
131
|
+
` ${key(column.name)}: ${ts}${column.nullable ? ' | null' : ''};`,
|
|
126
132
|
);
|
|
127
133
|
}
|
|
128
134
|
lines.push('}');
|
|
@@ -133,8 +139,8 @@ function emitRowInterfaces(table: IrTable): string {
|
|
|
133
139
|
const ts = appTsType(column);
|
|
134
140
|
lines.push(
|
|
135
141
|
column.nullable
|
|
136
|
-
? ` ${
|
|
137
|
-
: ` ${
|
|
142
|
+
? ` ${key(column.name)}?: ${ts} | null;`
|
|
143
|
+
: ` ${key(column.name)}: ${ts};`,
|
|
138
144
|
);
|
|
139
145
|
}
|
|
140
146
|
lines.push('}');
|
|
@@ -146,10 +152,10 @@ function emitRowInterfaces(table: IrTable): string {
|
|
|
146
152
|
for (const column of table.columns) {
|
|
147
153
|
const ts = appTsType(column);
|
|
148
154
|
if (column.name === table.primaryKey) {
|
|
149
|
-
lines.push(` ${
|
|
155
|
+
lines.push(` ${key(column.name)}: ${ts};`);
|
|
150
156
|
} else {
|
|
151
157
|
lines.push(
|
|
152
|
-
` ${
|
|
158
|
+
` ${key(column.name)}?: ${ts}${column.nullable ? ' | null' : ''};`,
|
|
153
159
|
);
|
|
154
160
|
}
|
|
155
161
|
}
|
|
@@ -157,25 +163,6 @@ function emitRowInterfaces(table: IrTable): string {
|
|
|
157
163
|
return lines.join('\n');
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
/**
|
|
161
|
-
* A Kysely-compatible `Database` interface: table name → Row type. This is
|
|
162
|
-
* the generic `@syncular/kysely`'s `SyncularDialect` is parameterized by
|
|
163
|
-
* (`new Kysely<Database>({ dialect })`). Additive — the Row interfaces it
|
|
164
|
-
* references are already emitted above. Property keys use the raw table name
|
|
165
|
-
* (quoted when not an identifier) so `db.selectFrom('table-name')` types.
|
|
166
|
-
*/
|
|
167
|
-
function emitDatabaseInterface(ir: IrDocument): string {
|
|
168
|
-
const lines: string[] = [];
|
|
169
|
-
lines.push('/** Kysely `Database` interface (table → Row); the generic for');
|
|
170
|
-
lines.push(" * @syncular/kysely's SyncularDialect. */");
|
|
171
|
-
lines.push('export interface Database {');
|
|
172
|
-
for (const table of ir.tables) {
|
|
173
|
-
lines.push(` ${propertyKey(table.name)}: ${pascalCase(table.name)}Row;`);
|
|
174
|
-
}
|
|
175
|
-
lines.push('}');
|
|
176
|
-
return lines.join('\n');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
166
|
function emitSubscription(sub: IrSubscription): string {
|
|
180
167
|
const params: string[] = [];
|
|
181
168
|
for (const scope of sub.scopes) {
|
|
@@ -218,7 +205,11 @@ function emitSubscription(sub: IrSubscription): string {
|
|
|
218
205
|
return lines.join('\n');
|
|
219
206
|
}
|
|
220
207
|
|
|
221
|
-
export function emitModule(
|
|
208
|
+
export function emitModule(
|
|
209
|
+
ir: IrDocument,
|
|
210
|
+
hash: string,
|
|
211
|
+
naming: NamingMode = 'camel',
|
|
212
|
+
): string {
|
|
222
213
|
const parts: string[] = [];
|
|
223
214
|
parts.push(
|
|
224
215
|
[
|
|
@@ -229,9 +220,8 @@ export function emitModule(ir: IrDocument, hash: string): string {
|
|
|
229
220
|
);
|
|
230
221
|
parts.push(emitSchema(ir));
|
|
231
222
|
for (const table of ir.tables) {
|
|
232
|
-
parts.push(emitRowInterfaces(table));
|
|
223
|
+
parts.push(emitRowInterfaces(table, naming));
|
|
233
224
|
}
|
|
234
|
-
parts.push(emitDatabaseInterface(ir));
|
|
235
225
|
for (const sub of ir.subscriptions) {
|
|
236
226
|
parts.push(emitSubscription(sub));
|
|
237
227
|
}
|