Skip to content

Commit 054ad6c

Browse files
Improve null handling for project_field and project_field_value (#17)
* update types and docs to reflect nullability * update docs * improve Joi validation * put optional fields constraints in the database * enhance docs * filter should be allow(null) as well * make project_field and project_field_value required for insert * revert "make project_field and project_field_value required for insert" * comment on Joi schema for project_field and project_field_value * comment on Joi schema design * remove useless export
1 parent 16371ca commit 054ad6c

File tree

8 files changed

+133
-53
lines changed

8 files changed

+133
-53
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ retrieved at any point by using the [listing endpoint](#service-api-list-rules).
9797
#### Unfiltered Rule <a name="service-api-unfiltered-rule"></a>
9898

9999
If a Rule is specified with no filter, **any** issue associated with the
100-
[incoming events](#sync-events) will be registered to the board.
100+
[incoming events](#sync-events) will be registered to the board. As noted by
101+
[`IssueToProjectFieldRuleCreationInput`](./src/server/types.ts),
102+
`"project_field"` and `"project_field_value"` can be (both) nullable, in which
103+
case the issue would be created to the board but no custom field would be
104+
assigned to it.
101105

102106
```
103107
curl \

src/core.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ export const syncIssue = async ({
172172
}: {
173173
issue: Issue
174174
graphql: typeof OctokitGraphQL
175-
project: { number: number; targetField?: string; targetValue?: string }
175+
project: {
176+
number: number
177+
targetField: string | null
178+
targetValue: string | null
179+
}
176180
organization: string
177181
}) => {
178182
const projectData = await fetchProjectData({

src/server/api.ts

Lines changed: 77 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -280,20 +280,71 @@ export const setupApi = (
280280
},
281281
)
282282

283+
/*
284+
For project_field and project_field_value, having the schema organized this
285+
way yields better error messages than Joi.alternative()
286+
*/
287+
const commonIssueToProjectFieldRuleCreationSchemaKeys = {
288+
project_field: Joi.string()
289+
.allow(null)
290+
/*
291+
We use ".." (the reference to the shared parent object of project_field
292+
and project_field since they're both on the same level in the payload's
293+
hierarchy) here because project_field and project_field_value value
294+
depend on one another, but Joi doesn't support circular references
295+
*/
296+
.when("..", {
297+
switch: [
298+
// If project_field_value is null, project_field should also be null
299+
{
300+
is: Joi.object()
301+
.keys({ project_field_value: Joi.valid(null).required() })
302+
.options({ allowUnknown: true }),
303+
then: Joi.valid(null).required(),
304+
},
305+
{
306+
// If project_field_value is a string, project_field should also be a string
307+
is: Joi.object()
308+
.keys({ project_field_value: Joi.string().required() })
309+
.options({ allowUnknown: true }),
310+
then: Joi.string().required(),
311+
},
312+
],
313+
}),
314+
project_field_value: Joi.string()
315+
.allow(null)
316+
/*
317+
We use ".." (the reference to the shared parent object of project_field
318+
and project_field since they're both on the same level in the payload's
319+
hierarchy) here because project_field and project_field_value value
320+
depend on one another, but Joi doesn't support circular references
321+
*/
322+
.when("..", {
323+
switch: [
324+
// If project_field is null, project_field_value should also be null
325+
{
326+
is: Joi.object()
327+
.keys({ project_field: Joi.valid(null).required() })
328+
.options({ allowUnknown: true }),
329+
then: Joi.valid(null).required(),
330+
},
331+
{
332+
// If project_field is a string, project_field_value should also be a string
333+
is: Joi.object()
334+
.keys({ project_field: Joi.string().required() })
335+
.options({ allowUnknown: true }),
336+
then: Joi.string().required(),
337+
},
338+
],
339+
}),
340+
filter: Joi.string().allow(null),
341+
}
342+
283343
const issueToProjectFieldRuleCreationSchema = Joi.object<
284-
Pick<
285-
IssueToProjectFieldRule,
286-
"project_number" | "project_field" | "project_field_value" | "filter"
287-
>
344+
Omit<IssueToProjectFieldRule, "id">
288345
>().keys({
346+
...commonIssueToProjectFieldRuleCreationSchemaKeys,
289347
project_number: Joi.number().required(),
290-
project_field: Joi.string(),
291-
project_field_value: Joi.string().when("project_field", {
292-
is: Joi.exist(),
293-
then: Joi.required(),
294-
otherwise: Joi.forbidden(),
295-
}),
296-
filter: Joi.string(),
297348
})
298349

299350
setupRoute(
@@ -315,23 +366,18 @@ export const setupApi = (
315366
}
316367

317368
const { value: payload } = bodyValidation
318-
const inputParams: DynamicQueryParam[] = []
319-
for (const [column, value] of Object.entries(payload)) {
320-
if (
321-
value === null ||
322-
(typeof value === "string" && value.length === 0)
323-
) {
324-
continue
325-
}
326-
inputParams.push({ column, value })
327-
}
328-
if (inputParams.length === 0) {
369+
const queryParams: DynamicQueryParam[] = Object.entries(payload).map(
370+
([column, value]) => {
371+
return { column, value }
372+
},
373+
)
374+
if (queryParams.length === 0) {
329375
return err(422, "Should provide at least one column")
330376
}
331377

332378
const rowCount = await database.wrapForDynamicQuery(
333379
[
334-
...inputParams,
380+
...queryParams,
335381
{ column: "github_owner", value: params.owner },
336382
{ column: "github_name", value: params.name },
337383
],
@@ -383,12 +429,10 @@ export const setupApi = (
383429
const issueToProjectFieldRuleUpdateSchema = Joi.object<
384430
ToOptional<Omit<IssueToProjectFieldRule, "id">>
385431
>().keys({
432+
...commonIssueToProjectFieldRuleCreationSchemaKeys,
386433
github_owner: Joi.string(),
387434
github_name: Joi.string(),
388435
project_number: Joi.number(),
389-
project_field: Joi.string(),
390-
project_field_value: Joi.string(),
391-
filter: Joi.string(),
392436
})
393437
setupRoute(
394438
"patch",
@@ -413,22 +457,17 @@ export const setupApi = (
413457
}
414458

415459
const { value: payload } = bodyValidation
416-
const inputParams: DynamicQueryParam[] = []
417-
for (const [column, value] of Object.entries(payload)) {
418-
if (
419-
value === null ||
420-
(typeof value === "string" && value.length === 0)
421-
) {
422-
continue
423-
}
424-
inputParams.push({ column, value })
425-
}
426-
if (inputParams.length === 0) {
460+
const queryParams: DynamicQueryParam[] = Object.entries(payload).map(
461+
([column, value]) => {
462+
return { column, value }
463+
},
464+
)
465+
if (queryParams.length === 0) {
427466
return err(422, "Should provide at least one column")
428467
}
429468

430469
const { rows, rowCount } = await database.wrapForDynamicQuery(
431-
inputParams,
470+
queryParams,
432471
async (client, { updateStatementParamsJoined, values }) => {
433472
const { rows, rowCount } = await client.query(
434473
`

src/server/event.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ export const setupBotEvents = (ctx: Context) => {
7979
graphql: octokit.graphql,
8080
project: {
8181
number: rule.project_number,
82-
targetField: rule.project_field,
83-
targetValue: rule.project_field_value,
82+
targetField: rule.project_field ?? null,
83+
targetValue: rule.project_field_value ?? null,
8484
},
8585
organization: rule.github_owner,
8686
})

src/server/migrations/1645593356645_issue_to_project_field_rule.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import type { MigrationBuilder } from "node-pg-migrate"
22

3-
const issueToProjectFieldRuleTable = "issue_to_project_field_rule"
3+
export const issueToProjectFieldRuleTable = "issue_to_project_field_rule"
4+
export const projectField = "project_field"
5+
export const projectFieldValue = "project_field_value"
6+
47
export const up = async (pgm: MigrationBuilder) => {
58
pgm.createTable(issueToProjectFieldRuleTable, {
69
id: { type: "SERIAL", primaryKey: true },
710
github_owner: { type: "TEXT", notNull: true },
811
github_name: { type: "TEXT", notNull: true },
912
project_number: { type: "INT", notNull: true },
10-
project_field: { type: "TEXT", notNull: true },
11-
project_field_value: { type: "TEXT", notNull: true },
13+
[projectField]: { type: "TEXT", notNull: true },
14+
[projectFieldValue]: { type: "TEXT", notNull: true },
1215
filter: { type: "TEXT" },
1316
})
1417
}

src/server/migrations/1651166162160_field_becomes_optional.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { MigrationBuilder } from "node-pg-migrate"
22

3-
const issueToProjectFieldRuleTable = "issue_to_project_field_rule"
4-
const projectField = "project_field"
5-
const projectFieldValue = "project_field_value"
3+
import {
4+
issueToProjectFieldRuleTable,
5+
projectField,
6+
projectFieldValue,
7+
} from "./1645593356645_issue_to_project_field_rule"
68

79
export const up = async (pgm: MigrationBuilder) => {
810
pgm.alterColumn(issueToProjectFieldRuleTable, projectField, {
@@ -15,9 +17,9 @@ export const up = async (pgm: MigrationBuilder) => {
1517

1618
export const down = async (pgm: MigrationBuilder) => {
1719
pgm.sql(`
18-
DELETE FROM ${issueToProjectFieldRuleTable}
19-
WHERE
20-
${projectField} IS NULL
20+
DELETE FROM ${issueToProjectFieldRuleTable}
21+
WHERE
22+
${projectField} IS NULL
2123
OR ${projectFieldValue} IS NULL
2224
`)
2325
pgm.alterColumn(issueToProjectFieldRuleTable, projectField, {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { MigrationBuilder } from "node-pg-migrate"
2+
3+
import {
4+
issueToProjectFieldRuleTable,
5+
projectField,
6+
projectFieldValue,
7+
} from "./1645593356645_issue_to_project_field_rule"
8+
9+
const projectFieldBothNullableOrRequiredConstraint =
10+
"project_field_both_nullable_or_required"
11+
12+
export const up = async (pgm: MigrationBuilder) => {
13+
pgm.addConstraint(
14+
issueToProjectFieldRuleTable,
15+
projectFieldBothNullableOrRequiredConstraint,
16+
`CHECK (
17+
(${projectField} is null AND ${projectFieldValue} is null) OR
18+
(${projectField} is not null AND ${projectFieldValue} is not null)
19+
)`,
20+
)
21+
}
22+
23+
export const down = async (pgm: MigrationBuilder) => {
24+
pgm.dropConstraint(
25+
issueToProjectFieldRuleTable,
26+
projectFieldBothNullableOrRequiredConstraint,
27+
)
28+
}

src/server/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ export type Context = {
2727

2828
type IssueToProjectFieldRuleCreationInput = {
2929
project_number: number
30-
project_field: string
31-
project_field_value: string
30+
project_field: string | null
31+
project_field_value: string | null
3232
filter?: string | null
3333
}
3434
type IssueToProjectFieldRuleUpdateInput =

0 commit comments

Comments
 (0)