@syncular/typegen 0.2.0 → 0.3.0

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.
Files changed (51) hide show
  1. package/README.md +25 -10
  2. package/dist/cli.js +155 -4
  3. package/dist/emit-dart.js +3 -2
  4. package/dist/emit-kotlin.js +3 -2
  5. package/dist/emit-queries-dart.js +87 -15
  6. package/dist/emit-queries-kotlin.js +89 -15
  7. package/dist/emit-queries-swift.js +92 -14
  8. package/dist/emit-queries.js +131 -22
  9. package/dist/emit-swift.js +3 -2
  10. package/dist/emit.d.ts +2 -1
  11. package/dist/emit.js +13 -27
  12. package/dist/fmt.d.ts +3 -0
  13. package/dist/fmt.js +356 -0
  14. package/dist/generate.d.ts +16 -6
  15. package/dist/generate.js +34 -9
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.js +6 -0
  18. package/dist/lower.d.ts +54 -0
  19. package/dist/lower.js +283 -0
  20. package/dist/lsp.d.ts +20 -0
  21. package/dist/lsp.js +376 -0
  22. package/dist/manifest.d.ts +11 -0
  23. package/dist/manifest.js +20 -0
  24. package/dist/naming.d.ts +22 -0
  25. package/dist/naming.js +240 -0
  26. package/dist/query-ir.d.ts +14 -0
  27. package/dist/query-ir.js +69 -0
  28. package/dist/query.d.ts +102 -6
  29. package/dist/query.js +126 -34
  30. package/dist/syql.d.ts +83 -0
  31. package/dist/syql.js +945 -0
  32. package/package.json +4 -4
  33. package/src/cli.ts +155 -4
  34. package/src/emit-dart.ts +3 -2
  35. package/src/emit-kotlin.ts +3 -2
  36. package/src/emit-queries-dart.ts +109 -16
  37. package/src/emit-queries-kotlin.ts +111 -18
  38. package/src/emit-queries-swift.ts +106 -17
  39. package/src/emit-queries.ts +179 -28
  40. package/src/emit-swift.ts +3 -2
  41. package/src/emit.ts +18 -28
  42. package/src/fmt.ts +351 -0
  43. package/src/generate.ts +54 -11
  44. package/src/index.ts +6 -0
  45. package/src/lower.ts +331 -0
  46. package/src/lsp.ts +445 -0
  47. package/src/manifest.ts +38 -0
  48. package/src/naming.ts +271 -0
  49. package/src/query-ir.ts +82 -0
  50. package/src/query.ts +241 -34
  51. package/src/syql.ts +1171 -0
@@ -11,7 +11,8 @@
11
11
  * separate output). Header carries the IR hash for byte-exact `--check`.
12
12
  */
13
13
  import type { IrColumnType } from './ir';
14
- import type { AnalyzedQuery, QueryColumn } from './query';
14
+ import { snakeToCamel } from './naming';
15
+ import type { AnalyzedQuery, QueryColumn, QueryParam } from './query';
15
16
 
16
17
  const KOTLIN_TYPE: Readonly<Record<IrColumnType, string>> = {
17
18
  string: 'String',
@@ -32,9 +33,9 @@ function pascalCase(name: string): string {
32
33
  .join('');
33
34
  }
34
35
 
36
+ /** Language-facing field name — the pinned §12 naming map. */
35
37
  function camelCase(name: string): string {
36
- const pascal = pascalCase(name);
37
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
38
+ return snakeToCamel(name);
38
39
  }
39
40
 
40
41
  function typeName(name: string): string {
@@ -46,7 +47,7 @@ function quote(value: string): string {
46
47
  }
47
48
 
48
49
  function rowAccessor(column: QueryColumn): string {
49
- const key = `row[${quote(column.name)}]`;
50
+ const key = `row[${quote(column.langName)}]`;
50
51
  switch (column.type) {
51
52
  case 'string':
52
53
  case 'json':
@@ -81,6 +82,45 @@ function paramValue(type: IrColumnType, name: string): string {
81
82
  }
82
83
  }
83
84
 
85
+ /** Bind for a §4 OPTIONAL param: `null` rides as JSON null (the §7
86
+ * neutralization guards make it a no-op). */
87
+ function optionalParamValue(type: IrColumnType, name: string): string {
88
+ switch (type) {
89
+ case 'integer':
90
+ return `${name}?.let { JsonValue.of(it.toDouble()) } ?: JsonValue.Null`;
91
+ case 'bytes':
92
+ case 'crdt':
93
+ return `${name}?.let { queryBindBytes(it) } ?: JsonValue.Null`;
94
+ default:
95
+ return `${name}?.let { JsonValue.of(it) } ?: JsonValue.Null`;
96
+ }
97
+ }
98
+
99
+ function isOptionalParam(query: AnalyzedQuery, p: QueryParam): boolean {
100
+ return (
101
+ p.optional === true ||
102
+ p.flag === true ||
103
+ (query.limit !== undefined && p.name === 'limit')
104
+ );
105
+ }
106
+
107
+ /** Per-query orderBy allowlist enum (column = the checked SQL column). */
108
+ function emitOrderByEnum(query: AnalyzedQuery): string[] {
109
+ if (query.orderBy === undefined) return [];
110
+ const lines: string[] = [];
111
+ lines.push(
112
+ `/** §6 orderBy allowlist for ${query.name} — checked at generate time. */`,
113
+ );
114
+ lines.push(`enum class ${typeName(query.name)}OrderBy(val column: String) {`);
115
+ lines.push(
116
+ query.orderBy.allowed
117
+ .map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
118
+ .join(',\n') + ';',
119
+ );
120
+ lines.push('}');
121
+ return lines;
122
+ }
123
+
84
124
  function emitDataClass(query: AnalyzedQuery): string[] {
85
125
  const Row = `${typeName(query.name)}Row`;
86
126
  const lines: string[] = [];
@@ -89,14 +129,14 @@ function emitDataClass(query: AnalyzedQuery): string[] {
89
129
  for (const column of query.columns) {
90
130
  const opt = column.nullable ? '?' : '';
91
131
  lines.push(
92
- ` val ${camelCase(column.name)}: ${KOTLIN_TYPE[column.type]}${opt},`,
132
+ ` val ${camelCase(column.langName)}: ${KOTLIN_TYPE[column.type]}${opt},`,
93
133
  );
94
134
  }
95
135
  lines.push(') {');
96
136
  lines.push(' companion object {');
97
137
  lines.push(` fun fromRow(row: JsonValue): ${Row}? {`);
98
138
  for (const column of query.columns) {
99
- const name = camelCase(column.name);
139
+ const name = camelCase(column.langName);
100
140
  const accessor = rowAccessor(column);
101
141
  if (column.nullable) {
102
142
  lines.push(` val ${name} = ${accessor}`);
@@ -104,7 +144,7 @@ function emitDataClass(query: AnalyzedQuery): string[] {
104
144
  lines.push(` val ${name} = ${accessor} ?: return null`);
105
145
  }
106
146
  }
107
- const args = query.columns.map((c) => camelCase(c.name)).join(', ');
147
+ const args = query.columns.map((c) => camelCase(c.langName)).join(', ');
108
148
  lines.push(` return ${Row}(${args})`);
109
149
  lines.push(' }');
110
150
  lines.push(' }');
@@ -118,30 +158,69 @@ function emitRunner(query: AnalyzedQuery): string[] {
118
158
  lines.push(
119
159
  ` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`,
120
160
  );
121
- lines.push(
122
- ` private const val ${query.name}Sql = ${quote(query.positionalSql)}`,
123
- );
161
+ if (query.orderBy !== undefined) {
162
+ lines.push(
163
+ ` private const val ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`,
164
+ );
165
+ } else {
166
+ lines.push(
167
+ ` private const val ${query.name}Sql = ${quote(query.positionalSql)}`,
168
+ );
169
+ }
124
170
  lines.push('');
125
171
  lines.push(` /** Run the ${query.name} named query (SELECT-only). */`);
126
- const args = query.params
127
- .map((p) => `${camelCase(p.name)}: ${KOTLIN_TYPE[p.type]}`)
128
- .join(', ');
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}: ${KOTLIN_TYPE[p.type]}? = null`);
177
+ } else {
178
+ args.push(`${name}: ${KOTLIN_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(
187
+ `orderBy: ${typeName(query.name)}OrderBy = ${typeName(query.name)}OrderBy.${defaultCase}`,
188
+ );
189
+ args.push(
190
+ `dir: SyncularQueryDir = SyncularQueryDir.${query.orderBy.defaultDir.toUpperCase()}`,
191
+ );
192
+ }
129
193
  const signature =
130
- query.params.length > 0
131
- ? `client: SyncularClient, ${args}`
194
+ args.length > 0
195
+ ? `client: SyncularClient, ${args.join(', ')}`
132
196
  : 'client: SyncularClient';
133
197
  lines.push(` fun ${query.name}(${signature}): List<${Row}> {`);
198
+ if (query.orderBy !== undefined) {
199
+ const limitTail =
200
+ query.positionalLimitTail !== undefined
201
+ ? ` + ${quote(query.positionalLimitTail)}`
202
+ : '';
203
+ lines.push(
204
+ ` val sql = ${query.name}SqlBase + " order by " + orderBy.column + " " + dir.sql${limitTail}`,
205
+ );
206
+ }
207
+ const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
134
208
  if (query.params.length > 0) {
135
209
  const binds = query.params
136
- .map((p) => paramValue(p.type, camelCase(p.name)))
210
+ .map((p) => {
211
+ const name = camelCase(p.langName);
212
+ return isOptionalParam(query, p)
213
+ ? optionalParamValue(p.type, name)
214
+ : paramValue(p.type, name);
215
+ })
137
216
  .join(', ');
138
217
  lines.push(` val params = listOf(${binds})`);
139
218
  lines.push(
140
- ` return client.query(${query.name}Sql, params).mapNotNull { ${Row}.fromRow(it) }`,
219
+ ` return client.query(${sqlRef}, params).mapNotNull { ${Row}.fromRow(it) }`,
141
220
  );
142
221
  } else {
143
222
  lines.push(
144
- ` return client.query(${query.name}Sql).mapNotNull { ${Row}.fromRow(it) }`,
223
+ ` return client.query(${sqlRef}).mapNotNull { ${Row}.fromRow(it) }`,
145
224
  );
146
225
  }
147
226
  lines.push(' }');
@@ -194,8 +273,22 @@ export function emitQueriesKotlinModule(
194
273
  ].join('\n'),
195
274
  );
196
275
 
276
+ if (queries.some((q) => q.orderBy !== undefined)) {
277
+ parts.push(
278
+ [
279
+ '/** §6 orderBy direction (shared by every orderBy-knob query). */',
280
+ 'enum class SyncularQueryDir(val sql: String) {',
281
+ ' ASC("asc"),',
282
+ ' DESC("desc");',
283
+ '}',
284
+ ].join('\n'),
285
+ );
286
+ }
287
+
197
288
  for (const query of queries) {
198
289
  parts.push(emitDataClass(query).join('\n'));
290
+ const orderByEnum = emitOrderByEnum(query);
291
+ if (orderByEnum.length > 0) parts.push(orderByEnum.join('\n'));
199
292
  }
200
293
 
201
294
  const objLines: string[] = [];
@@ -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 type { AnalyzedQuery, QueryColumn } from './query';
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
- const pascal = pascalCase(name);
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.name)}]`;
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.name)}: ${SWIFT_TYPE[column.type]}${opt}`,
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.name);
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
- lines.push(
120
- ` private static let ${query.name}Sql = ${quote(query.positionalSql)}`,
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 = query.params
125
- .map((p) => `${camelCase(p.name)}: ${SWIFT_TYPE[p.type]}`)
126
- .join(', ');
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
- query.params.length > 0
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) => paramValue(p.type, camelCase(p.name)))
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(${query.name}Sql, params: params).compactMap { row in`,
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(${query.name}Sql).compactMap { row in`,
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[] = [];