@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 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