Skip to content

Commit 9e474d3

Browse files
authored
Improve error messages on invalid YAML nodes (#612)
1 parent f20f318 commit 9e474d3

4 files changed

Lines changed: 69 additions & 28 deletions

File tree

.changeset/sharp-adults-greet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/service-sync-rules': patch
3+
---
4+
5+
Improve error messages when invalid YAML nodes are used where a scalar value is expected.

packages/sync-rules/src/from_yaml.ts

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,27 @@ export class SyncConfigFromYaml {
108108

109109
// Validate that there are no additional properties.
110110
// Since these errors don't contain line numbers, do this last.
111-
const valid = validateSyncRulesSchema(parsed.toJSON());
112-
if (!valid) {
113-
this.#errors.push(
114-
...validateSyncRulesSchema.errors!.map((e: any) => {
115-
return new YamlError(e);
116-
})
117-
);
111+
if (!this.#hasFatalError) {
112+
const valid = validateSyncRulesSchema(parsed.toJSON());
113+
if (!valid) {
114+
this.#errors.push(
115+
...validateSyncRulesSchema.errors!.map((e: any) => {
116+
return new YamlError(e);
117+
})
118+
);
119+
}
118120
}
121+
119122
this.#throwOnErrorIfRequested();
120123
return result;
121124
}
122125

126+
get #hasFatalError(): boolean {
127+
return this.#errors.find((e) => e.type != 'warning') != null;
128+
}
129+
123130
#throwOnErrorIfRequested() {
124-
if (this.options.throwOnError && this.#errors.find((e) => e.type != 'warning') != null) {
131+
if (this.options.throwOnError && this.#hasFatalError) {
125132
throw new SyncRulesErrors(this.#errors);
126133
}
127134
}
@@ -192,7 +199,7 @@ export class SyncConfigFromYaml {
192199
const map = new Map();
193200
if (from != null) {
194201
for (const entry of from.items ?? []) {
195-
const { key: cteNameScalar, value: cteQuery } = entry as { key: Scalar<string>; value: Scalar };
202+
const { key: cteNameScalar, value: cteQuery } = entry as { key: Scalar<string>; value: Node };
196203
const cteName = cteNameScalar.value;
197204

198205
if (this.options.schema) {
@@ -208,10 +215,12 @@ export class SyncConfigFromYaml {
208215
}
209216
}
210217

211-
const [sql, errorListener] = this.#scalarErrorListener(cteQuery);
212-
const parsed = compiler.commonTableExpression(sql, errorListener);
213-
if (parsed) {
214-
map.set(cteName, parsed);
218+
if (this.#expectScalar(cteQuery)) {
219+
const [sql, errorListener] = this.#scalarErrorListener(cteQuery);
220+
const parsed = compiler.commonTableExpression(sql, errorListener);
221+
if (parsed) {
222+
map.set(cteName, parsed);
223+
}
215224
}
216225
}
217226
}
@@ -248,12 +257,14 @@ export class SyncConfigFromYaml {
248257
streamCompiler.registerCommonTableExpression(name, query)
249258
);
250259

251-
const addQuery = (query: Scalar<string>) => {
252-
const [sql, errorListener] = this.#scalarErrorListener(query);
253-
streamCompiler.addQuery(sql, errorListener);
260+
const addQuery = (query: Node) => {
261+
if (this.#expectScalar(query)) {
262+
const [sql, errorListener] = this.#scalarErrorListener(query);
263+
streamCompiler.addQuery(sql, errorListener);
264+
}
254265
};
255266

256-
const queries = value.get('queries') as YAMLSeq | null;
267+
const queries = value.get('queries') as YAMLSeq<Node> | null;
257268
const query = value.get('query', true) as Scalar<string> | null;
258269

259270
if ((queries == null) == (query == null)) {
@@ -264,9 +275,7 @@ export class SyncConfigFromYaml {
264275
}
265276
if (queries) {
266277
for (const queryEntry of queries.items) {
267-
if (queryEntry instanceof Scalar) {
268-
addQuery(queryEntry as Scalar<string>);
269-
}
278+
addQuery(queryEntry);
270279
}
271280
}
272281

@@ -518,7 +527,20 @@ export class SyncConfigFromYaml {
518527
return new YamlError(new Error(message), { start, end });
519528
}
520529

521-
#withScalar(scalar: Scalar, cb: (value: string) => QueryParseResult) {
530+
#expectScalar(node: Node): node is Scalar {
531+
if (!(node instanceof Scalar)) {
532+
this.#errors.push(this.#yamlError(node, 'Expected a scalar value here.'));
533+
return false;
534+
}
535+
536+
return true;
537+
}
538+
539+
#withScalar(scalar: Scalar, cb: (value: string) => QueryParseResult): void {
540+
if (!this.#expectScalar(scalar)) {
541+
return;
542+
}
543+
522544
const value = scalar.toString();
523545

524546
const wrapped = (value: string): QueryParseResult => {
@@ -536,7 +558,7 @@ export class SyncConfigFromYaml {
536558
for (let err of result.errors) {
537559
this.#addErrorFromScalar(scalar, value, err);
538560
}
539-
return result;
561+
return;
540562
}
541563

542564
/**

packages/sync-rules/test/src/compiler/errors.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,4 +730,24 @@ streams:
730730
}
731731
]);
732732
});
733+
734+
test('arrays where literals are expected', () => {
735+
const [errors, _] = yamlToSyncPlan(`
736+
config:
737+
edition: 3
738+
739+
streams:
740+
manybuckets:
741+
with:
742+
foo: # should not be an array
743+
- SELECT 1 as bar
744+
query: # should also not be an array (unless queries is used as a key)
745+
- SELECT * FROM tbl WHERE id IN foo
746+
`);
747+
748+
expect(errors).toStrictEqual([
749+
{ message: 'Expected a scalar value here.', source: '- SELECT 1 as bar\n' },
750+
{ message: 'Expected a scalar value here.', source: '- SELECT * FROM tbl WHERE id IN foo\n' }
751+
]);
752+
});
733753
});

packages/sync-rules/test/src/sync_rules.test.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -916,12 +916,6 @@ bucket_definitions:
916916
{
917917
message: "'mybucket' bucket definition must be an object",
918918
type: 'fatal'
919-
},
920-
// Ideally this should not be displayed - it's an additional JSON schema validation error
921-
// for the same issue. For now we just include both.
922-
{
923-
message: 'must be object',
924-
type: 'fatal'
925919
}
926920
]);
927921
});

0 commit comments

Comments
 (0)