@sqldoc/ns-validate 0.0.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/package.json +30 -0
- package/src/__tests__/validate.test.ts +272 -0
- package/src/index.ts +217 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@sqldoc/ns-validate",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "Validation namespace for sqldoc -- generates CHECK constraints",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"package.json"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@sqldoc/core": "0.0.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.9.3",
|
|
24
|
+
"vitest": "^4.1.0",
|
|
25
|
+
"@sqldoc/core": "0.0.1"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { TagContext } from '@sqldoc/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import plugin from '../index'
|
|
4
|
+
|
|
5
|
+
function makeCtx(overrides: Partial<TagContext> = {}): TagContext {
|
|
6
|
+
return {
|
|
7
|
+
target: 'column',
|
|
8
|
+
objectName: 'users',
|
|
9
|
+
columnName: 'username',
|
|
10
|
+
columnType: 'text',
|
|
11
|
+
tag: { name: 'check', args: {} },
|
|
12
|
+
namespaceTags: [],
|
|
13
|
+
siblingTags: [],
|
|
14
|
+
fileTags: [],
|
|
15
|
+
astNode: null,
|
|
16
|
+
fileStatements: [],
|
|
17
|
+
config: {},
|
|
18
|
+
filePath: 'test.sql',
|
|
19
|
+
...overrides,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('ns-validate plugin', () => {
|
|
24
|
+
it('exports apiVersion === 1', () => {
|
|
25
|
+
expect(plugin.apiVersion).toBe(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('exports name === "validate"', () => {
|
|
29
|
+
expect(plugin.name).toBe('validate')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('has all tag entries', () => {
|
|
33
|
+
expect(plugin.tags).toHaveProperty('check')
|
|
34
|
+
expect(plugin.tags).toHaveProperty('notEmpty')
|
|
35
|
+
expect(plugin.tags).toHaveProperty('range')
|
|
36
|
+
expect(plugin.tags).toHaveProperty('length')
|
|
37
|
+
expect(plugin.tags).toHaveProperty('pattern')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('onTag', () => {
|
|
41
|
+
it('@validate.check generates ALTER TABLE ADD CONSTRAINT with CHECK', () => {
|
|
42
|
+
const ctx = makeCtx({
|
|
43
|
+
tag: { name: 'check', args: ['length(username) >= 3'] },
|
|
44
|
+
})
|
|
45
|
+
const result = plugin.onTag!(ctx) as any
|
|
46
|
+
expect(result.sql).toEqual([
|
|
47
|
+
{
|
|
48
|
+
sql: 'ALTER TABLE "users" ADD CONSTRAINT "users_username_check" CHECK (length(username) >= 3);',
|
|
49
|
+
},
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('@validate.notEmpty generates CHECK with length(trim(col)) > 0', () => {
|
|
54
|
+
const ctx = makeCtx({
|
|
55
|
+
columnName: 'email',
|
|
56
|
+
tag: { name: 'notEmpty', args: {} },
|
|
57
|
+
})
|
|
58
|
+
const result = plugin.onTag!(ctx) as any
|
|
59
|
+
expect(result.sql).toEqual([
|
|
60
|
+
{
|
|
61
|
+
sql: 'ALTER TABLE "users" ADD CONSTRAINT "users_email_not_empty" CHECK (length(trim("email")) > 0);',
|
|
62
|
+
},
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('@validate.range generates CHECK with min/max bounds', () => {
|
|
67
|
+
const ctx = makeCtx({
|
|
68
|
+
columnName: 'age',
|
|
69
|
+
columnType: 'integer',
|
|
70
|
+
tag: { name: 'range', args: { min: 0, max: 150 } },
|
|
71
|
+
})
|
|
72
|
+
const result = plugin.onTag!(ctx) as any
|
|
73
|
+
expect(result.sql).toEqual([
|
|
74
|
+
{
|
|
75
|
+
sql: 'ALTER TABLE "users" ADD CONSTRAINT "users_age_range" CHECK ("age" >= 0 AND "age" <= 150);',
|
|
76
|
+
},
|
|
77
|
+
])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('@validate.length with both min and max generates combined CHECK', () => {
|
|
81
|
+
const ctx = makeCtx({
|
|
82
|
+
tag: { name: 'length', args: { min: 3, max: 50 } },
|
|
83
|
+
})
|
|
84
|
+
const result = plugin.onTag!(ctx) as any
|
|
85
|
+
expect(result.sql).toEqual([
|
|
86
|
+
{
|
|
87
|
+
sql: 'ALTER TABLE "users" ADD CONSTRAINT "users_username_length" CHECK (length("username") >= 3 AND length("username") <= 50);',
|
|
88
|
+
},
|
|
89
|
+
])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('@validate.length with only min generates >= CHECK', () => {
|
|
93
|
+
const ctx = makeCtx({
|
|
94
|
+
tag: { name: 'length', args: { min: 3 } },
|
|
95
|
+
})
|
|
96
|
+
const result = plugin.onTag!(ctx) as any
|
|
97
|
+
expect(result.sql).toEqual([
|
|
98
|
+
{
|
|
99
|
+
sql: 'ALTER TABLE "users" ADD CONSTRAINT "users_username_length" CHECK (length("username") >= 3);',
|
|
100
|
+
},
|
|
101
|
+
])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('@validate.length with only max generates <= CHECK', () => {
|
|
105
|
+
const ctx = makeCtx({
|
|
106
|
+
tag: { name: 'length', args: { max: 255 } },
|
|
107
|
+
})
|
|
108
|
+
const result = plugin.onTag!(ctx) as any
|
|
109
|
+
expect(result.sql).toEqual([
|
|
110
|
+
{
|
|
111
|
+
sql: 'ALTER TABLE "users" ADD CONSTRAINT "users_username_length" CHECK (length("username") <= 255);',
|
|
112
|
+
},
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('@validate.pattern generates CHECK with regex operator', () => {
|
|
117
|
+
const ctx = makeCtx({
|
|
118
|
+
columnName: 'email',
|
|
119
|
+
tag: { name: 'pattern', args: ['^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'] },
|
|
120
|
+
})
|
|
121
|
+
const result = plugin.onTag!(ctx) as any
|
|
122
|
+
expect(result.sql).toEqual([
|
|
123
|
+
{
|
|
124
|
+
sql: `ALTER TABLE "users" ADD CONSTRAINT "users_email_pattern" CHECK ("email" ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$');`,
|
|
125
|
+
},
|
|
126
|
+
])
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('validation', () => {
|
|
131
|
+
it('@validate.notEmpty warns on non-text column', () => {
|
|
132
|
+
const validate = plugin.tags.notEmpty.validate!
|
|
133
|
+
const result = validate({
|
|
134
|
+
target: 'column',
|
|
135
|
+
lines: [],
|
|
136
|
+
siblingTags: [],
|
|
137
|
+
fileTags: [],
|
|
138
|
+
argValues: {},
|
|
139
|
+
columnName: 'age',
|
|
140
|
+
columnType: 'integer',
|
|
141
|
+
objectName: 'users',
|
|
142
|
+
})
|
|
143
|
+
expect(result).toEqual({
|
|
144
|
+
message: 'notEmpty is typically used on text columns',
|
|
145
|
+
severity: 'warning',
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('@validate.notEmpty does not warn on text column', () => {
|
|
150
|
+
const validate = plugin.tags.notEmpty.validate!
|
|
151
|
+
const result = validate({
|
|
152
|
+
target: 'column',
|
|
153
|
+
lines: [],
|
|
154
|
+
siblingTags: [],
|
|
155
|
+
fileTags: [],
|
|
156
|
+
argValues: {},
|
|
157
|
+
columnName: 'name',
|
|
158
|
+
columnType: 'text',
|
|
159
|
+
objectName: 'users',
|
|
160
|
+
})
|
|
161
|
+
expect(result).toBeUndefined()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('@validate.notEmpty does not warn on varchar column', () => {
|
|
165
|
+
const validate = plugin.tags.notEmpty.validate!
|
|
166
|
+
const result = validate({
|
|
167
|
+
target: 'column',
|
|
168
|
+
lines: [],
|
|
169
|
+
siblingTags: [],
|
|
170
|
+
fileTags: [],
|
|
171
|
+
argValues: {},
|
|
172
|
+
columnName: 'name',
|
|
173
|
+
columnType: 'varchar(255)',
|
|
174
|
+
objectName: 'users',
|
|
175
|
+
})
|
|
176
|
+
expect(result).toBeUndefined()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('@validate.range errors when min >= max', () => {
|
|
180
|
+
const validate = plugin.tags.range.validate!
|
|
181
|
+
const result = validate({
|
|
182
|
+
target: 'column',
|
|
183
|
+
lines: [],
|
|
184
|
+
siblingTags: [],
|
|
185
|
+
fileTags: [],
|
|
186
|
+
argValues: { min: 10, max: 5 },
|
|
187
|
+
columnName: 'age',
|
|
188
|
+
columnType: 'integer',
|
|
189
|
+
objectName: 'users',
|
|
190
|
+
})
|
|
191
|
+
expect(result).toBe('min must be less than max')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('@validate.range errors when min equals max', () => {
|
|
195
|
+
const validate = plugin.tags.range.validate!
|
|
196
|
+
const result = validate({
|
|
197
|
+
target: 'column',
|
|
198
|
+
lines: [],
|
|
199
|
+
siblingTags: [],
|
|
200
|
+
fileTags: [],
|
|
201
|
+
argValues: { min: 5, max: 5 },
|
|
202
|
+
columnName: 'age',
|
|
203
|
+
columnType: 'integer',
|
|
204
|
+
objectName: 'users',
|
|
205
|
+
})
|
|
206
|
+
expect(result).toBe('min must be less than max')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('@validate.range passes with valid min < max', () => {
|
|
210
|
+
const validate = plugin.tags.range.validate!
|
|
211
|
+
const result = validate({
|
|
212
|
+
target: 'column',
|
|
213
|
+
lines: [],
|
|
214
|
+
siblingTags: [],
|
|
215
|
+
fileTags: [],
|
|
216
|
+
argValues: { min: 0, max: 100 },
|
|
217
|
+
columnName: 'age',
|
|
218
|
+
columnType: 'integer',
|
|
219
|
+
objectName: 'users',
|
|
220
|
+
})
|
|
221
|
+
expect(result).toBeUndefined()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('@validate.length errors when neither min nor max provided', () => {
|
|
225
|
+
const validate = plugin.tags.length.validate!
|
|
226
|
+
const result = validate({
|
|
227
|
+
target: 'column',
|
|
228
|
+
lines: [],
|
|
229
|
+
siblingTags: [],
|
|
230
|
+
fileTags: [],
|
|
231
|
+
argValues: {},
|
|
232
|
+
columnName: 'name',
|
|
233
|
+
columnType: 'text',
|
|
234
|
+
objectName: 'users',
|
|
235
|
+
})
|
|
236
|
+
expect(result).toBe('at least one of min or max is required')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('@validate.length errors when min >= max', () => {
|
|
240
|
+
const validate = plugin.tags.length.validate!
|
|
241
|
+
const result = validate({
|
|
242
|
+
target: 'column',
|
|
243
|
+
lines: [],
|
|
244
|
+
siblingTags: [],
|
|
245
|
+
fileTags: [],
|
|
246
|
+
argValues: { min: 50, max: 10 },
|
|
247
|
+
columnName: 'name',
|
|
248
|
+
columnType: 'text',
|
|
249
|
+
objectName: 'users',
|
|
250
|
+
})
|
|
251
|
+
expect(result).toBe('min must be less than max')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('@validate.length warns on non-text column', () => {
|
|
255
|
+
const validate = plugin.tags.length.validate!
|
|
256
|
+
const result = validate({
|
|
257
|
+
target: 'column',
|
|
258
|
+
lines: [],
|
|
259
|
+
siblingTags: [],
|
|
260
|
+
fileTags: [],
|
|
261
|
+
argValues: { min: 1, max: 100 },
|
|
262
|
+
columnName: 'count',
|
|
263
|
+
columnType: 'integer',
|
|
264
|
+
objectName: 'users',
|
|
265
|
+
})
|
|
266
|
+
expect(result).toEqual({
|
|
267
|
+
message: 'length check is typically used on text columns',
|
|
268
|
+
severity: 'warning',
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { NamespacePlugin, TagContext, TagOutput } from '@sqldoc/core'
|
|
2
|
+
|
|
3
|
+
function isTextType(type: string | undefined): boolean {
|
|
4
|
+
if (!type) return false
|
|
5
|
+
const t = type.toLowerCase()
|
|
6
|
+
return (
|
|
7
|
+
t === 'text' ||
|
|
8
|
+
t.startsWith('varchar') ||
|
|
9
|
+
t.startsWith('character varying') ||
|
|
10
|
+
t === 'char' ||
|
|
11
|
+
t.startsWith('character')
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const plugin: NamespacePlugin = {
|
|
16
|
+
apiVersion: 1,
|
|
17
|
+
name: 'validate',
|
|
18
|
+
tags: {
|
|
19
|
+
check: {
|
|
20
|
+
description: 'Add a CHECK constraint with a custom expression',
|
|
21
|
+
targets: ['column'],
|
|
22
|
+
args: {
|
|
23
|
+
positional: { type: 'string', required: true },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
notEmpty: {
|
|
27
|
+
description: 'Add a CHECK constraint ensuring the column is not empty (length(trim(col)) > 0)',
|
|
28
|
+
targets: ['column'],
|
|
29
|
+
validate: (ctx) => {
|
|
30
|
+
if (!isTextType(ctx.columnType)) {
|
|
31
|
+
return { message: 'notEmpty is typically used on text columns', severity: 'warning' }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
range: {
|
|
36
|
+
description: 'Add a CHECK constraint ensuring the column value is within a numeric range',
|
|
37
|
+
targets: ['column'],
|
|
38
|
+
args: {
|
|
39
|
+
min: { type: 'number', required: true },
|
|
40
|
+
max: { type: 'number', required: true },
|
|
41
|
+
},
|
|
42
|
+
validate: (ctx) => {
|
|
43
|
+
const args = ctx.argValues as Record<string, unknown>
|
|
44
|
+
const min = args.min as number
|
|
45
|
+
const max = args.max as number
|
|
46
|
+
if (min >= max) {
|
|
47
|
+
return 'min must be less than max'
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
length: {
|
|
52
|
+
description: 'Add a CHECK constraint on string length',
|
|
53
|
+
targets: ['column'],
|
|
54
|
+
args: {
|
|
55
|
+
min: { type: 'number', required: false },
|
|
56
|
+
max: { type: 'number', required: false },
|
|
57
|
+
},
|
|
58
|
+
validate: (ctx) => {
|
|
59
|
+
const args = ctx.argValues as Record<string, unknown>
|
|
60
|
+
if (args.min == null && args.max == null) {
|
|
61
|
+
return 'at least one of min or max is required'
|
|
62
|
+
}
|
|
63
|
+
if (args.min != null && args.max != null && (args.min as number) >= (args.max as number)) {
|
|
64
|
+
return 'min must be less than max'
|
|
65
|
+
}
|
|
66
|
+
if (!isTextType(ctx.columnType)) {
|
|
67
|
+
return { message: 'length check is typically used on text columns', severity: 'warning' }
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
pattern: {
|
|
72
|
+
description: 'Add a CHECK constraint using a PostgreSQL regex pattern',
|
|
73
|
+
targets: ['column'],
|
|
74
|
+
args: [{ type: 'string' }],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
onTag(ctx: TagContext): TagOutput | undefined {
|
|
79
|
+
const { tag, objectName, columnName } = ctx
|
|
80
|
+
|
|
81
|
+
switch (tag.name) {
|
|
82
|
+
case 'check': {
|
|
83
|
+
const expression = Array.isArray(tag.args)
|
|
84
|
+
? (tag.args[0] as string)
|
|
85
|
+
: ((tag.args as Record<string, unknown>).positional as string)
|
|
86
|
+
return {
|
|
87
|
+
sql: [
|
|
88
|
+
{
|
|
89
|
+
sql: `ALTER TABLE "${objectName}" ADD CONSTRAINT "${objectName}_${columnName}_check" CHECK (${expression});`,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case 'notEmpty': {
|
|
96
|
+
return {
|
|
97
|
+
sql: [
|
|
98
|
+
{
|
|
99
|
+
sql: `ALTER TABLE "${objectName}" ADD CONSTRAINT "${objectName}_${columnName}_not_empty" CHECK (length(trim("${columnName}")) > 0);`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
docs: {
|
|
103
|
+
columns: [{ header: 'Validation', object: objectName, column: columnName, value: 'Not empty' }],
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case 'range': {
|
|
109
|
+
const args = tag.args as Record<string, unknown>
|
|
110
|
+
const min = args.min as number
|
|
111
|
+
const max = args.max as number
|
|
112
|
+
return {
|
|
113
|
+
sql: [
|
|
114
|
+
{
|
|
115
|
+
sql: `ALTER TABLE "${objectName}" ADD CONSTRAINT "${objectName}_${columnName}_range" CHECK ("${columnName}" >= ${min} AND "${columnName}" <= ${max});`,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
docs: {
|
|
119
|
+
columns: [{ header: 'Validation', object: objectName, column: columnName, value: `Range: ${min}–${max}` }],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'length': {
|
|
125
|
+
const args = tag.args as Record<string, unknown>
|
|
126
|
+
const min = args.min as number | undefined
|
|
127
|
+
const max = args.max as number | undefined
|
|
128
|
+
let checkExpr: string
|
|
129
|
+
let label: string
|
|
130
|
+
if (min != null && max != null) {
|
|
131
|
+
checkExpr = `length("${columnName}") >= ${min} AND length("${columnName}") <= ${max}`
|
|
132
|
+
label = `Length: ${min}–${max}`
|
|
133
|
+
} else if (min != null) {
|
|
134
|
+
checkExpr = `length("${columnName}") >= ${min}`
|
|
135
|
+
label = `Min length: ${min}`
|
|
136
|
+
} else {
|
|
137
|
+
checkExpr = `length("${columnName}") <= ${max}`
|
|
138
|
+
label = `Max length: ${max}`
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
sql: [
|
|
142
|
+
{
|
|
143
|
+
sql: `ALTER TABLE "${objectName}" ADD CONSTRAINT "${objectName}_${columnName}_length" CHECK (${checkExpr});`,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
docs: {
|
|
147
|
+
columns: [{ header: 'Validation', object: objectName, column: columnName, value: label }],
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case 'pattern': {
|
|
153
|
+
const pattern = Array.isArray(tag.args)
|
|
154
|
+
? (tag.args[0] as string)
|
|
155
|
+
: ((tag.args as Record<string, unknown>).positional as string)
|
|
156
|
+
return {
|
|
157
|
+
sql: [
|
|
158
|
+
{
|
|
159
|
+
sql: `ALTER TABLE "${objectName}" ADD CONSTRAINT "${objectName}_${columnName}_pattern" CHECK ("${columnName}" ~ '${pattern}');`,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
docs: {
|
|
163
|
+
columns: [{ header: 'Validation', object: objectName, column: columnName, value: `Pattern: ${pattern}` }],
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
default:
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
lintRules: [
|
|
174
|
+
{
|
|
175
|
+
name: 'validate.require-pk',
|
|
176
|
+
description: 'Tables should have a primary key',
|
|
177
|
+
default: 'warn',
|
|
178
|
+
check(ctx) {
|
|
179
|
+
const diagnostics = []
|
|
180
|
+
for (const output of ctx.outputs) {
|
|
181
|
+
for (const obj of output.fileTags) {
|
|
182
|
+
if (obj.target !== 'table') continue
|
|
183
|
+
// Skip objects that look like column-level tags (table.column)
|
|
184
|
+
if (obj.objectName.includes('.')) continue
|
|
185
|
+
|
|
186
|
+
// Check if any tag indicates a primary key exists
|
|
187
|
+
// We need to check the Atlas realm if available, or inspect the source SQL
|
|
188
|
+
const hasPk = checkTableHasPk(output, obj.objectName)
|
|
189
|
+
if (!hasPk) {
|
|
190
|
+
diagnostics.push({
|
|
191
|
+
objectName: obj.objectName,
|
|
192
|
+
sourceFile: output.sourceFile,
|
|
193
|
+
message: `Table '${obj.objectName}' has no primary key`,
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return diagnostics
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Check if a table has a primary key by inspecting the source SQL */
|
|
205
|
+
function checkTableHasPk(output: import('@sqldoc/core').CompilerOutput, tableName: string): boolean {
|
|
206
|
+
const sql = output.mergedSql.toLowerCase()
|
|
207
|
+
// Look for PRIMARY KEY in the CREATE TABLE statement for this table
|
|
208
|
+
const tableRegex = new RegExp(
|
|
209
|
+
`create\\s+table\\s+(?:if\\s+not\\s+exists\\s+)?(?:"${tableName.toLowerCase()}"|${tableName.toLowerCase()})\\s*\\(([^;]*?)\\)`,
|
|
210
|
+
'is',
|
|
211
|
+
)
|
|
212
|
+
const match = sql.match(tableRegex)
|
|
213
|
+
if (!match) return true // If we can't find the table, don't flag it
|
|
214
|
+
return match[1].includes('primary key')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export default plugin
|