@syncular/typegen 0.2.1 → 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 +2 -2
  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
@@ -1,3 +1,4 @@
1
+ import { snakeToCamel } from './naming.js';
1
2
  const KOTLIN_TYPE = {
2
3
  string: 'String',
3
4
  integer: 'Long',
@@ -15,9 +16,9 @@ function pascalCase(name) {
15
16
  .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
16
17
  .join('');
17
18
  }
19
+ /** Language-facing field name — the pinned §12 naming map. */
18
20
  function camelCase(name) {
19
- const pascal = pascalCase(name);
20
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
21
+ return snakeToCamel(name);
21
22
  }
22
23
  function typeName(name) {
23
24
  return name.charAt(0).toUpperCase() + name.slice(1);
@@ -26,7 +27,7 @@ function quote(value) {
26
27
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
27
28
  }
28
29
  function rowAccessor(column) {
29
- const key = `row[${quote(column.name)}]`;
30
+ const key = `row[${quote(column.langName)}]`;
30
31
  switch (column.type) {
31
32
  case 'string':
32
33
  case 'json':
@@ -59,6 +60,37 @@ function paramValue(type, name) {
59
60
  return `JsonValue.of(${name})`;
60
61
  }
61
62
  }
63
+ /** Bind for a §4 OPTIONAL param: `null` rides as JSON null (the §7
64
+ * neutralization guards make it a no-op). */
65
+ function optionalParamValue(type, name) {
66
+ switch (type) {
67
+ case 'integer':
68
+ return `${name}?.let { JsonValue.of(it.toDouble()) } ?: JsonValue.Null`;
69
+ case 'bytes':
70
+ case 'crdt':
71
+ return `${name}?.let { queryBindBytes(it) } ?: JsonValue.Null`;
72
+ default:
73
+ return `${name}?.let { JsonValue.of(it) } ?: JsonValue.Null`;
74
+ }
75
+ }
76
+ function isOptionalParam(query, p) {
77
+ return (p.optional === true ||
78
+ p.flag === true ||
79
+ (query.limit !== undefined && p.name === 'limit'));
80
+ }
81
+ /** Per-query orderBy allowlist enum (column = the checked SQL column). */
82
+ function emitOrderByEnum(query) {
83
+ if (query.orderBy === undefined)
84
+ return [];
85
+ const lines = [];
86
+ lines.push(`/** §6 orderBy allowlist for ${query.name} — checked at generate time. */`);
87
+ lines.push(`enum class ${typeName(query.name)}OrderBy(val column: String) {`);
88
+ lines.push(query.orderBy.allowed
89
+ .map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
90
+ .join(',\n') + ';');
91
+ lines.push('}');
92
+ return lines;
93
+ }
62
94
  function emitDataClass(query) {
63
95
  const Row = `${typeName(query.name)}Row`;
64
96
  const lines = [];
@@ -66,13 +98,13 @@ function emitDataClass(query) {
66
98
  lines.push(`data class ${Row}(`);
67
99
  for (const column of query.columns) {
68
100
  const opt = column.nullable ? '?' : '';
69
- lines.push(` val ${camelCase(column.name)}: ${KOTLIN_TYPE[column.type]}${opt},`);
101
+ lines.push(` val ${camelCase(column.langName)}: ${KOTLIN_TYPE[column.type]}${opt},`);
70
102
  }
71
103
  lines.push(') {');
72
104
  lines.push(' companion object {');
73
105
  lines.push(` fun fromRow(row: JsonValue): ${Row}? {`);
74
106
  for (const column of query.columns) {
75
- const name = camelCase(column.name);
107
+ const name = camelCase(column.langName);
76
108
  const accessor = rowAccessor(column);
77
109
  if (column.nullable) {
78
110
  lines.push(` val ${name} = ${accessor}`);
@@ -81,7 +113,7 @@ function emitDataClass(query) {
81
113
  lines.push(` val ${name} = ${accessor} ?: return null`);
82
114
  }
83
115
  }
84
- const args = query.columns.map((c) => camelCase(c.name)).join(', ');
116
+ const args = query.columns.map((c) => camelCase(c.langName)).join(', ');
85
117
  lines.push(` return ${Row}(${args})`);
86
118
  lines.push(' }');
87
119
  lines.push(' }');
@@ -92,25 +124,55 @@ function emitRunner(query) {
92
124
  const Row = `${typeName(query.name)}Row`;
93
125
  const lines = [];
94
126
  lines.push(` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`);
95
- lines.push(` private const val ${query.name}Sql = ${quote(query.positionalSql)}`);
127
+ if (query.orderBy !== undefined) {
128
+ lines.push(` private const val ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`);
129
+ }
130
+ else {
131
+ lines.push(` private const val ${query.name}Sql = ${quote(query.positionalSql)}`);
132
+ }
96
133
  lines.push('');
97
134
  lines.push(` /** Run the ${query.name} named query (SELECT-only). */`);
98
- const args = query.params
99
- .map((p) => `${camelCase(p.name)}: ${KOTLIN_TYPE[p.type]}`)
100
- .join(', ');
101
- const signature = query.params.length > 0
102
- ? `client: SyncularClient, ${args}`
135
+ const args = [];
136
+ for (const p of query.params) {
137
+ const name = camelCase(p.langName);
138
+ if (isOptionalParam(query, p)) {
139
+ args.push(`${name}: ${KOTLIN_TYPE[p.type]}? = null`);
140
+ }
141
+ else {
142
+ args.push(`${name}: ${KOTLIN_TYPE[p.type]}`);
143
+ }
144
+ }
145
+ if (query.orderBy !== undefined) {
146
+ const defaultCase = camelCase(query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
147
+ ?.langName ?? query.orderBy.defaultColumn);
148
+ args.push(`orderBy: ${typeName(query.name)}OrderBy = ${typeName(query.name)}OrderBy.${defaultCase}`);
149
+ args.push(`dir: SyncularQueryDir = SyncularQueryDir.${query.orderBy.defaultDir.toUpperCase()}`);
150
+ }
151
+ const signature = args.length > 0
152
+ ? `client: SyncularClient, ${args.join(', ')}`
103
153
  : 'client: SyncularClient';
104
154
  lines.push(` fun ${query.name}(${signature}): List<${Row}> {`);
155
+ if (query.orderBy !== undefined) {
156
+ const limitTail = query.positionalLimitTail !== undefined
157
+ ? ` + ${quote(query.positionalLimitTail)}`
158
+ : '';
159
+ lines.push(` val sql = ${query.name}SqlBase + " order by " + orderBy.column + " " + dir.sql${limitTail}`);
160
+ }
161
+ const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
105
162
  if (query.params.length > 0) {
106
163
  const binds = query.params
107
- .map((p) => paramValue(p.type, camelCase(p.name)))
164
+ .map((p) => {
165
+ const name = camelCase(p.langName);
166
+ return isOptionalParam(query, p)
167
+ ? optionalParamValue(p.type, name)
168
+ : paramValue(p.type, name);
169
+ })
108
170
  .join(', ');
109
171
  lines.push(` val params = listOf(${binds})`);
110
- lines.push(` return client.query(${query.name}Sql, params).mapNotNull { ${Row}.fromRow(it) }`);
172
+ lines.push(` return client.query(${sqlRef}, params).mapNotNull { ${Row}.fromRow(it) }`);
111
173
  }
112
174
  else {
113
- lines.push(` return client.query(${query.name}Sql).mapNotNull { ${Row}.fromRow(it) }`);
175
+ lines.push(` return client.query(${sqlRef}).mapNotNull { ${Row}.fromRow(it) }`);
114
176
  }
115
177
  lines.push(' }');
116
178
  return lines;
@@ -149,8 +211,20 @@ export function emitQueriesKotlinModule(queries, hash, irVersion, packageName, o
149
211
  ' return JsonValue.obj("\\$bytes" to JsonValue.of(hex))',
150
212
  '}',
151
213
  ].join('\n'));
214
+ if (queries.some((q) => q.orderBy !== undefined)) {
215
+ parts.push([
216
+ '/** §6 orderBy direction (shared by every orderBy-knob query). */',
217
+ 'enum class SyncularQueryDir(val sql: String) {',
218
+ ' ASC("asc"),',
219
+ ' DESC("desc");',
220
+ '}',
221
+ ].join('\n'));
222
+ }
152
223
  for (const query of queries) {
153
224
  parts.push(emitDataClass(query).join('\n'));
225
+ const orderByEnum = emitOrderByEnum(query);
226
+ if (orderByEnum.length > 0)
227
+ parts.push(orderByEnum.join('\n'));
154
228
  }
155
229
  const objLines = [];
156
230
  objLines.push('/** Typed named queries (the sqlc/SQLDelight tier). */');
@@ -1,3 +1,4 @@
1
+ import { snakeToCamel } from './naming.js';
1
2
  const SWIFT_TYPE = {
2
3
  string: 'String',
3
4
  integer: 'Int',
@@ -15,9 +16,9 @@ function pascalCase(name) {
15
16
  .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
16
17
  .join('');
17
18
  }
19
+ /** Language-facing field name — the pinned §12 naming map. */
18
20
  function camelCase(name) {
19
- const pascal = pascalCase(name);
20
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
21
+ return snakeToCamel(name);
21
22
  }
22
23
  /** The query function/type name: already camelCase; PascalCase for the type. */
23
24
  function typeName(name) {
@@ -27,7 +28,7 @@ function quote(value) {
27
28
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
28
29
  }
29
30
  function rowAccessor(column) {
30
- const key = `row[${quote(column.name)}]`;
31
+ const key = `row[${quote(column.langName)}]`;
31
32
  switch (column.type) {
32
33
  case 'string':
33
34
  case 'json':
@@ -58,6 +59,28 @@ function paramValue(type, name) {
58
59
  return `.string(${name})`;
59
60
  }
60
61
  }
62
+ /** Bind for a §4 OPTIONAL param: `nil` rides as JSON null (the §7
63
+ * neutralization guards make it a no-op). */
64
+ function optionalParamValue(type, name) {
65
+ switch (type) {
66
+ case 'integer':
67
+ return `${name}.map { JSONValue.number(Double($0)) } ?? .null`;
68
+ case 'float':
69
+ return `${name}.map { JSONValue.number($0) } ?? .null`;
70
+ case 'boolean':
71
+ return `${name}.map { JSONValue.bool($0) } ?? .null`;
72
+ case 'bytes':
73
+ case 'crdt':
74
+ return `${name}.map { SyncularSchemaQueryBind.bytes($0) } ?? .null`;
75
+ default:
76
+ return `${name}.map { JSONValue.string($0) } ?? .null`;
77
+ }
78
+ }
79
+ function isOptionalParam(query, p) {
80
+ return (p.optional === true ||
81
+ p.flag === true ||
82
+ (query.limit !== undefined && p.name === 'limit'));
83
+ }
61
84
  function emitRowStruct(query) {
62
85
  const Row = `${typeName(query.name)}Row`;
63
86
  const lines = [];
@@ -65,12 +88,12 @@ function emitRowStruct(query) {
65
88
  lines.push(`public struct ${Row}: Sendable, Equatable {`);
66
89
  for (const column of query.columns) {
67
90
  const opt = column.nullable ? '?' : '';
68
- lines.push(` public let ${camelCase(column.name)}: ${SWIFT_TYPE[column.type]}${opt}`);
91
+ lines.push(` public let ${camelCase(column.langName)}: ${SWIFT_TYPE[column.type]}${opt}`);
69
92
  }
70
93
  lines.push('');
71
94
  lines.push(' public init?(row: [String: JSONValue]) {');
72
95
  for (const column of query.columns) {
73
- const name = camelCase(column.name);
96
+ const name = camelCase(column.langName);
74
97
  const accessor = rowAccessor(column);
75
98
  if (column.nullable) {
76
99
  lines.push(` self.${name} = ${accessor}`);
@@ -84,29 +107,72 @@ function emitRowStruct(query) {
84
107
  lines.push('}');
85
108
  return lines;
86
109
  }
110
+ /** Per-query orderBy allowlist enum (rawValue = the checked SQL column). */
111
+ function emitOrderByEnum(query) {
112
+ if (query.orderBy === undefined)
113
+ return [];
114
+ const lines = [];
115
+ lines.push(`/// §6 orderBy allowlist for ${query.name} — checked at generate time.`);
116
+ lines.push(`public enum ${typeName(query.name)}OrderBy: String, Sendable {`);
117
+ for (const col of query.orderBy.allowed) {
118
+ lines.push(` case ${camelCase(col.langName)} = ${quote(col.name)}`);
119
+ }
120
+ lines.push('}');
121
+ return lines;
122
+ }
87
123
  function emitRunner(query) {
88
124
  const Row = `${typeName(query.name)}Row`;
89
125
  const lines = [];
90
126
  lines.push(` public static let ${query.name}Tables = [${query.tables.map(quote).join(', ')}]`);
91
- lines.push(` private static let ${query.name}Sql = ${quote(query.positionalSql)}`);
127
+ if (query.orderBy !== undefined) {
128
+ lines.push(` private static let ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`);
129
+ }
130
+ else {
131
+ lines.push(` private static let ${query.name}Sql = ${quote(query.positionalSql)}`);
132
+ }
92
133
  lines.push('');
93
134
  lines.push(` /// Run the ${query.name} named query (SELECT-only).`);
94
- const args = query.params
95
- .map((p) => `${camelCase(p.name)}: ${SWIFT_TYPE[p.type]}`)
96
- .join(', ');
97
- const signature = query.params.length > 0
98
- ? `client: SyncularClient, ${args}`
135
+ const args = [];
136
+ for (const p of query.params) {
137
+ const name = camelCase(p.langName);
138
+ if (isOptionalParam(query, p)) {
139
+ args.push(`${name}: ${SWIFT_TYPE[p.type]}? = nil`);
140
+ }
141
+ else {
142
+ args.push(`${name}: ${SWIFT_TYPE[p.type]}`);
143
+ }
144
+ }
145
+ if (query.orderBy !== undefined) {
146
+ const defaultCase = camelCase(query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
147
+ ?.langName ?? query.orderBy.defaultColumn);
148
+ args.push(`orderBy: ${typeName(query.name)}OrderBy = .${defaultCase}`);
149
+ args.push(`dir: SyncularQueryDir = .${query.orderBy.defaultDir}`);
150
+ }
151
+ const signature = args.length > 0
152
+ ? `client: SyncularClient, ${args.join(', ')}`
99
153
  : 'client: SyncularClient';
100
154
  lines.push(` public static func ${query.name}(${signature}) throws -> [${Row}] {`);
155
+ const sqlExpr = query.orderBy !== undefined
156
+ ? `${query.name}SqlBase + " order by " + orderBy.rawValue + " " + dir.rawValue${query.positionalLimitTail !== undefined ? ` + ${quote(query.positionalLimitTail)}` : ''}`
157
+ : `${query.name}Sql`;
158
+ if (query.orderBy !== undefined) {
159
+ lines.push(` let sql = ${sqlExpr}`);
160
+ }
161
+ const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
101
162
  if (query.params.length > 0) {
102
163
  const binds = query.params
103
- .map((p) => paramValue(p.type, camelCase(p.name)))
164
+ .map((p) => {
165
+ const name = camelCase(p.langName);
166
+ return isOptionalParam(query, p)
167
+ ? optionalParamValue(p.type, name)
168
+ : paramValue(p.type, name);
169
+ })
104
170
  .join(', ');
105
171
  lines.push(` let params: [JSONValue] = [${binds}]`);
106
- lines.push(` return try client.query(${query.name}Sql, params: params).compactMap { row in`);
172
+ lines.push(` return try client.query(${sqlRef}, params: params).compactMap { row in`);
107
173
  }
108
174
  else {
109
- lines.push(` return try client.query(${query.name}Sql).compactMap { row in`);
175
+ lines.push(` return try client.query(${sqlRef}).compactMap { row in`);
110
176
  }
111
177
  lines.push(' guard case let .object(fields) = row else { return nil }');
112
178
  lines.push(` return ${Row}(row: fields)`);
@@ -133,8 +199,20 @@ export function emitQueriesSwiftModule(queries, hash, irVersion, enumName) {
133
199
  ' }',
134
200
  '}',
135
201
  ].join('\n'));
202
+ if (queries.some((q) => q.orderBy !== undefined)) {
203
+ parts.push([
204
+ '/// §6 orderBy direction (shared by every orderBy-knob query).',
205
+ 'public enum SyncularQueryDir: String, Sendable {',
206
+ ' case asc = "asc"',
207
+ ' case desc = "desc"',
208
+ '}',
209
+ ].join('\n'));
210
+ }
136
211
  for (const query of queries) {
137
212
  parts.push(emitRowStruct(query).join('\n'));
213
+ const orderByEnum = emitOrderByEnum(query);
214
+ if (orderByEnum.length > 0)
215
+ parts.push(orderByEnum.join('\n'));
138
216
  }
139
217
  const enumLines = [];
140
218
  enumLines.push('/// Typed named queries (the sqlc/SQLDelight tier).');
@@ -20,61 +20,167 @@ function propertyKey(name) {
20
20
  }
21
21
  /** A named-query param value is the SqlValue subset its type maps to. */
22
22
  const PARAM_TS_TYPE = TS_TYPE;
23
+ function isOptional(param) {
24
+ return param.optional === true || param.flag === true;
25
+ }
26
+ /** The positional bind expression for one param. `access` is `params` or
27
+ * `params?` depending on whether the params object itself may be absent. */
28
+ function bindExpr(query, param, access) {
29
+ if (query.limit !== undefined && param.name === 'limit') {
30
+ // The default + clamp live IN the SQL (`min(coalesce(?, d), m)`).
31
+ return `${access}.limit ?? null`;
32
+ }
33
+ const key = propertyKey(param.langName);
34
+ return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
35
+ }
23
36
  function emitQuery(query) {
24
37
  const Row = `${pascalCase(query.name)}Row`;
25
38
  const Params = `${pascalCase(query.name)}Params`;
26
39
  const lines = [];
27
- // Row interface.
40
+ // Row interface — keyed by the language-facing names, which ARE the
41
+ // runtime result keys (§5 projection lowering aliases them in SQL).
28
42
  lines.push(`/** One row of the ${quote(query.name)} query (its projection). */`);
29
43
  lines.push(`export interface ${Row} {`);
30
44
  for (const column of query.columns) {
31
- lines.push(` ${propertyKey(column.name)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`);
45
+ lines.push(` ${propertyKey(column.langName)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`);
32
46
  }
33
47
  lines.push('}');
34
48
  lines.push('');
35
- // Params interface (only when there are params).
36
- const hasParams = query.params.length > 0;
49
+ const hasParams = query.params.length > 0 || query.orderBy !== undefined;
50
+ const requiresParams = query.params.some((p) => !isOptional(p) && !(query.limit !== undefined && p.name === 'limit'));
51
+ // Params interface (params and/or knob keys).
37
52
  if (hasParams) {
38
53
  lines.push(`/** Named parameters for ${quote(query.name)}. */`);
39
54
  lines.push(`export interface ${Params} {`);
40
55
  for (const param of query.params) {
41
- lines.push(` ${propertyKey(param.name)}: ${PARAM_TS_TYPE[param.type]};`);
56
+ if (query.limit !== undefined && param.name === 'limit')
57
+ continue;
58
+ const opt = isOptional(param);
59
+ lines.push(` ${propertyKey(param.langName)}${opt ? '?' : ''}: ${PARAM_TS_TYPE[param.type]}${opt ? ' | null' : ''};`);
60
+ }
61
+ if (query.orderBy !== undefined) {
62
+ const keys = query.orderBy.allowed
63
+ .map((c) => quote(c.langName))
64
+ .join(' | ');
65
+ lines.push(` /** §6 orderBy knob — a generate-time-checked allowlist. */`);
66
+ lines.push(` orderBy?: ${keys};`);
67
+ lines.push(` dir?: 'asc' | 'desc';`);
68
+ }
69
+ if (query.limit !== undefined) {
70
+ const clamp = query.limit.max !== undefined ? ` (clamped to ${query.limit.max})` : '';
71
+ lines.push(` /** §6 limit knob — binds as a value${clamp}; default ${query.limit.default ?? query.limit.max}. */`);
72
+ lines.push(' limit?: number;');
42
73
  }
43
74
  lines.push('}');
44
75
  lines.push('');
45
76
  }
46
- // Tables dependency set (for useSyncQuery {tables} / useNamedQuery).
47
- lines.push(`/** Tables ${quote(query.name)} reads — the exact useSyncQuery \`{tables}\` set. */`);
77
+ // Tables dependency set (for useRawSql {tables} / useQuery).
78
+ lines.push(`/** Tables ${quote(query.name)} reads — the exact useRawSql \`{tables}\` set. */`);
48
79
  lines.push(`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`);
49
80
  lines.push('');
50
- // The runner. SQL is the positional form; params reorder into the array.
51
- const paramArg = hasParams ? `params: ${Params}` : '';
81
+ // SQL constants: the static default statement, plus (orderBy knob) the
82
+ // baked base + column map + compose function.
52
83
  const sqlConst = `${query.name}Sql`;
84
+ lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
85
+ const composeFn = `${query.name}ComposeSql`;
86
+ if (query.orderBy !== undefined) {
87
+ const base = `${query.name}SqlBase`;
88
+ const colsConst = `${query.name}OrderColumns`;
89
+ const defaultLang = query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
90
+ ?.langName ?? query.orderBy.defaultColumn;
91
+ lines.push(`const ${base} = ${quote(query.positionalSqlBase ?? '')};`);
92
+ lines.push(`const ${colsConst} = { ${query.orderBy.allowed
93
+ .map((c) => `${propertyKey(c.langName)}: ${quote(c.name)}`)
94
+ .join(', ')} } as const;`);
95
+ lines.push(`function ${composeFn}(params?: ${Params}): string {`);
96
+ lines.push(` const column = ${colsConst}[params?.orderBy ?? ${quote(defaultLang)}] ?? ${quote(query.orderBy.defaultColumn)};`);
97
+ lines.push(` const dir = (params?.dir ?? ${quote(query.orderBy.defaultDir)}) === 'desc' ? 'desc' : 'asc';`);
98
+ lines.push(` return \`\${${base}} order by \${column} \${dir}${query.positionalLimitTail ?? ''}\`;`);
99
+ lines.push('}');
100
+ }
101
+ // Positional bind list.
102
+ const access = requiresParams ? 'params' : 'params?';
53
103
  const positional = hasParams
54
- ? `[${query.params.map((p) => `params.${propertyKey(p.name)}`).join(', ')}]`
104
+ ? `[${query.params.map((p) => bindExpr(query, p, access)).join(', ')}]`
55
105
  : '[]';
56
- lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
106
+ // §7 variant backend: one statement per provided-combination, selected by
107
+ // a bitmask over the optional groups (bit i = group i provided/true). The
108
+ // neutralization statement above stays the canonical/default form.
109
+ const selectFn = `${query.name}SelectVariant`;
110
+ const paramArgDecl = requiresParams
111
+ ? `params: ${Params}`
112
+ : `params?: ${Params}`;
113
+ if (query.variants !== undefined && query.variantGroups !== undefined) {
114
+ const variantsConst = `${query.name}Variants`;
115
+ lines.push(`const ${variantsConst}: { sql: string; bind: (${paramArgDecl}) => QueryValue[] }[] = [`);
116
+ for (const variant of query.variants) {
117
+ const binds = variant.params
118
+ .map((name) => {
119
+ const param = query.params.find((p) => p.name === name);
120
+ if (param === undefined)
121
+ throw new Error(`unknown param ${name}`);
122
+ return bindExpr(query, param, access);
123
+ })
124
+ .join(', ');
125
+ lines.push(` // [${variant.when.join(' + ') || 'no optional filters'}]`);
126
+ lines.push(` { sql: ${quote(variant.positionalSql)}, bind: (${requiresParams ? 'params' : 'params?'}) => [${binds}] },`);
127
+ }
128
+ lines.push('];');
129
+ lines.push(`function ${selectFn}(${paramArgDecl}): { sql: string; bind: QueryValue[] } {`);
130
+ lines.push(' let mask = 0;');
131
+ query.variantGroups.forEach((group, index) => {
132
+ const langOf = (name) => query.params.find((p) => p.name === name)?.langName ?? name;
133
+ const condition = group.flag
134
+ ? `params?.${propertyKey(langOf(group.params[0]))} === true`
135
+ : group.params
136
+ .map((name) => `(params?.${propertyKey(langOf(name))} ?? null) !== null`)
137
+ .join(' && ');
138
+ lines.push(` if (${condition}) mask |= ${1 << index};`);
139
+ });
140
+ lines.push(` const variant = ${variantsConst}[mask] as (typeof ${variantsConst})[number];`);
141
+ lines.push(' return { sql: variant.sql, bind: variant.bind(params) };');
142
+ lines.push('}');
143
+ }
57
144
  lines.push('');
145
+ // The runner.
146
+ const paramArg = !hasParams ? '' : paramArgDecl;
58
147
  lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
59
148
  lines.push(`export async function ${query.name}(client: QueryClient${hasParams ? `, ${paramArg}` : ''}): Promise<${Row}[]> {`);
60
- if (hasParams) {
61
- lines.push(` const rows = await client.query(${sqlConst}, ${positional});`);
149
+ if (query.variants !== undefined) {
150
+ lines.push(` const variant = ${selectFn}(params);`);
151
+ lines.push(' const rows = await client.query(variant.sql, variant.bind);');
62
152
  }
63
153
  else {
64
- lines.push(` const rows = await client.query(${sqlConst});`);
154
+ const sqlExpr = query.orderBy !== undefined ? `${composeFn}(params)` : sqlConst;
155
+ if (hasParams) {
156
+ lines.push(` const rows = await client.query(${sqlExpr}, ${positional});`);
157
+ }
158
+ else {
159
+ lines.push(` const rows = await client.query(${sqlExpr});`);
160
+ }
65
161
  }
66
162
  lines.push(` return rows as unknown as ${Row}[];`);
67
163
  lines.push('}');
68
164
  lines.push('');
69
- // A descriptor for react's `useNamedQuery` — the SQL, the exact table
70
- // dependency set, and a `bind(params)` positional array. Typed by the
71
- // query's own Row/Params so the hook stays fully typed.
72
- lines.push(`/** Descriptor for \`useNamedQuery(${query.name}Query${hasParams ? ', params' : ''})\` sql + tables + row type. */`);
165
+ // A descriptor for react's `useQuery` — the SQL (static, composed from
166
+ // the baked allowlist, or variant-selected), the exact table dependency
167
+ // set, and a `bind(params)` positional array (always paired with the
168
+ // sqlFor selection). Typed by the query's own Row/Params.
169
+ lines.push(`/** Descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\` — sql + tables + row type. */`);
73
170
  const paramsTypeArg = hasParams ? Params : 'undefined';
74
171
  lines.push(`export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`);
75
172
  lines.push(` sql: ${sqlConst},`);
173
+ if (query.variants !== undefined) {
174
+ lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
175
+ }
176
+ else if (query.orderBy !== undefined) {
177
+ lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
178
+ }
76
179
  lines.push(` tables: ${query.name}Tables,`);
77
- if (hasParams) {
180
+ if (query.variants !== undefined) {
181
+ lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
182
+ }
183
+ else if (hasParams) {
78
184
  lines.push(` bind: (params: ${Params}) => ${positional},`);
79
185
  }
80
186
  else {
@@ -112,13 +218,16 @@ export function emitQueriesModule(queries, hash, irVersion) {
112
218
  '',
113
219
  '/** A named-query descriptor — sql + its exact table dependency set + a',
114
220
  ' * `bind(params)` → positional args. Consumed by',
115
- " * `@syncular/react`'s `useNamedQuery`. `Row` is the projection row",
116
- ' * type; `Params` is `undefined` for a param-less query. */',
221
+ " * `@syncular/react`'s `useQuery`. `Row` is the projection row",
222
+ ' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
223
+ ' * (present only with an orderBy knob) composes the statement from a',
224
+ ' * generate-time-checked column allowlist. */',
117
225
  'export interface NamedQuery<Row, Params = undefined> {',
118
226
  ' readonly sql: string;',
119
227
  ' readonly tables: readonly string[];',
120
228
  ' readonly bind: (params: Params) => readonly QueryValue[];',
121
- ' /** Phantom carries the Row type for `useNamedQuery` inference. */',
229
+ ' readonly sqlFor?: (params: Params) => string;',
230
+ ' /** Phantom — carries the Row type for `useQuery` inference. */',
122
231
  ' readonly __row?: Row;',
123
232
  '}',
124
233
  ].join('\n'));
@@ -1,3 +1,4 @@
1
+ import { snakeToCamel } from './naming.js';
1
2
  /**
2
3
  * §2.4 column type → honest Swift type. `json`/`blob_ref` are the raw
3
4
  * canonical JSON string (the client's blob API parses blob_ref); `bytes`/
@@ -27,9 +28,9 @@ function pascalCase(name) {
27
28
  .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
28
29
  .join('');
29
30
  }
31
+ /** Language-facing field name — the pinned §12 naming map. */
30
32
  function camelCase(name) {
31
- const pascal = pascalCase(name);
32
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
33
+ return snakeToCamel(name);
33
34
  }
34
35
  function quote(value) {
35
36
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
package/dist/emit.d.ts CHANGED
@@ -13,4 +13,5 @@
13
13
  * byte-exactly.
14
14
  */
15
15
  import type { IrDocument } from './ir.js';
16
- export declare function emitModule(ir: IrDocument, hash: string): string;
16
+ import { type NamingMode } from './naming.js';
17
+ export declare function emitModule(ir: IrDocument, hash: string, naming?: NamingMode): string;