@sqldoc/core 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 +33 -0
- package/src/__tests__/ast/sqlparser-ts.test.ts +117 -0
- package/src/__tests__/blocks.test.ts +80 -0
- package/src/__tests__/compile.test.ts +407 -0
- package/src/__tests__/compiler/compile.test.ts +363 -0
- package/src/__tests__/lint-rules.test.ts +249 -0
- package/src/__tests__/lint.test.ts +270 -0
- package/src/__tests__/parser.test.ts +169 -0
- package/src/__tests__/tags.sql +15 -0
- package/src/__tests__/validator.test.ts +210 -0
- package/src/ast/adapter.ts +10 -0
- package/src/ast/index.ts +3 -0
- package/src/ast/sqlparser-ts.ts +218 -0
- package/src/ast/types.ts +28 -0
- package/src/blocks.ts +242 -0
- package/src/compiler/compile.ts +783 -0
- package/src/compiler/config.ts +102 -0
- package/src/compiler/index.ts +29 -0
- package/src/compiler/types.ts +320 -0
- package/src/index.ts +72 -0
- package/src/lint.ts +127 -0
- package/src/loader.ts +102 -0
- package/src/parser.ts +202 -0
- package/src/ts-import.ts +70 -0
- package/src/types.ts +111 -0
- package/src/utils.ts +31 -0
- package/src/validator.ts +324 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { SqlAstAdapter } from '../../ast/adapter'
|
|
3
|
+
import type { SqlStatement } from '../../ast/types'
|
|
4
|
+
import { compile } from '../../compiler/compile'
|
|
5
|
+
import type { NamespacePlugin, ResolvedConfig } from '../../compiler/types'
|
|
6
|
+
|
|
7
|
+
/** Stub adapter that returns no comments */
|
|
8
|
+
const stubAdapter: SqlAstAdapter = {
|
|
9
|
+
async init() {},
|
|
10
|
+
parseStatements() {
|
|
11
|
+
return []
|
|
12
|
+
},
|
|
13
|
+
parseComments() {
|
|
14
|
+
return []
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Test helpers ─────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function makePlugin(overrides: Partial<NamespacePlugin> & { name: string }): NamespacePlugin {
|
|
21
|
+
return {
|
|
22
|
+
apiVersion: 1,
|
|
23
|
+
tags: {},
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Helper to get 1-based line number of a substring in source */
|
|
29
|
+
function lineOf(source: string, search: string): number {
|
|
30
|
+
const idx = source.indexOf(search)
|
|
31
|
+
if (idx === -1) return 1
|
|
32
|
+
return source.substring(0, idx).split('\n').length
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeStatements(stmts: Partial<SqlStatement>[]): SqlStatement[] {
|
|
36
|
+
return stmts.map((s, _i) => ({
|
|
37
|
+
kind: s.kind ?? 'table',
|
|
38
|
+
objectName: s.objectName ?? 'unknown',
|
|
39
|
+
line: s.line ?? 1,
|
|
40
|
+
columns: s.columns,
|
|
41
|
+
raw: s.raw ?? null,
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe('compile()', () => {
|
|
48
|
+
it('returns SqlOutput[] in CompilerOutput.sqlOutputs from generateSQL', () => {
|
|
49
|
+
const plugin = makePlugin({
|
|
50
|
+
name: 'test',
|
|
51
|
+
tags: { label: { args: [{ type: 'string' }] } },
|
|
52
|
+
generateSQL: (ctx) => [{ sql: `-- generated for ${ctx.objectName}` }],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const source = ["-- @test.label('users-table')", 'CREATE TABLE users (', ' id SERIAL PRIMARY KEY', ');'].join('\n')
|
|
56
|
+
|
|
57
|
+
const result = compile({
|
|
58
|
+
source,
|
|
59
|
+
filePath: 'test.sql',
|
|
60
|
+
plugins: new Map([['test', plugin]]),
|
|
61
|
+
statements: makeStatements([{ kind: 'table', objectName: 'users', line: lineOf(source, 'CREATE') }]),
|
|
62
|
+
adapter: stubAdapter,
|
|
63
|
+
config: { dialect: 'postgres' },
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result.sqlOutputs).toHaveLength(1)
|
|
67
|
+
expect(result.sqlOutputs[0].sql).toBe('-- generated for users')
|
|
68
|
+
expect(result.sourceFile).toBe('test.sql')
|
|
69
|
+
// mergedSql should contain original source + generated SQL
|
|
70
|
+
expect(result.mergedSql).toContain('CREATE TABLE users')
|
|
71
|
+
expect(result.mergedSql).toContain('-- generated for users')
|
|
72
|
+
expect(result.mergedSql).toContain('-- Generated by sqldoc')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns CodeOutput[] in CompilerOutput.codeOutputs from generateCode', () => {
|
|
76
|
+
const plugin = makePlugin({
|
|
77
|
+
name: 'codegen',
|
|
78
|
+
tags: { types: {} },
|
|
79
|
+
generateCode: (ctx) => [{ filePath: `${ctx.objectName}.ts`, content: `export type ${ctx.objectName} = {}` }],
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const source = ['-- @codegen.types', 'CREATE TABLE orders (', ' id SERIAL', ');'].join('\n')
|
|
83
|
+
|
|
84
|
+
const result = compile({
|
|
85
|
+
source,
|
|
86
|
+
filePath: 'orders.sql',
|
|
87
|
+
plugins: new Map([['codegen', plugin]]),
|
|
88
|
+
statements: makeStatements([{ kind: 'table', objectName: 'orders', line: lineOf(source, 'CREATE') }]),
|
|
89
|
+
adapter: stubAdapter,
|
|
90
|
+
config: { dialect: 'postgres' },
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(result.codeOutputs).toHaveLength(1)
|
|
94
|
+
expect(result.codeOutputs[0].filePath).toBe('orders.ts')
|
|
95
|
+
expect(result.codeOutputs[0].content).toBe('export type orders = {}')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('populates CompilerContext.siblingTags with tags from other namespaces on same object', () => {
|
|
99
|
+
let capturedCtx: any = null
|
|
100
|
+
|
|
101
|
+
const pluginA = makePlugin({
|
|
102
|
+
name: 'alpha',
|
|
103
|
+
tags: { run: {} },
|
|
104
|
+
generateSQL: (ctx) => {
|
|
105
|
+
capturedCtx = ctx
|
|
106
|
+
return []
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const pluginB = makePlugin({
|
|
111
|
+
name: 'beta',
|
|
112
|
+
tags: { check: { args: [{ type: 'string' }] } },
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const source = ['-- @alpha.run', "-- @beta.check('strict')", 'CREATE TABLE products (', ' id SERIAL', ');'].join(
|
|
116
|
+
'\n',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
compile({
|
|
120
|
+
source,
|
|
121
|
+
filePath: 'products.sql',
|
|
122
|
+
plugins: new Map([
|
|
123
|
+
['alpha', pluginA],
|
|
124
|
+
['beta', pluginB],
|
|
125
|
+
]),
|
|
126
|
+
statements: makeStatements([{ kind: 'table', objectName: 'products', line: lineOf(source, 'CREATE') }]),
|
|
127
|
+
adapter: stubAdapter,
|
|
128
|
+
config: { dialect: 'postgres' },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(capturedCtx).not.toBeNull()
|
|
132
|
+
expect(capturedCtx.siblingTags).toEqual(
|
|
133
|
+
expect.arrayContaining([expect.objectContaining({ namespace: 'beta', tag: 'check' })]),
|
|
134
|
+
)
|
|
135
|
+
// siblingTags should include tags from ALL namespaces (including own)
|
|
136
|
+
expect(capturedCtx.siblingTags).toEqual(
|
|
137
|
+
expect.arrayContaining([expect.objectContaining({ namespace: 'alpha', tag: 'run' })]),
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('populates CompilerContext.fileTags with all tags across file grouped by object', () => {
|
|
142
|
+
let capturedCtx: any = null
|
|
143
|
+
|
|
144
|
+
const plugin = makePlugin({
|
|
145
|
+
name: 'ns',
|
|
146
|
+
tags: { a: {}, b: {} },
|
|
147
|
+
generateSQL: (ctx) => {
|
|
148
|
+
capturedCtx = ctx
|
|
149
|
+
return []
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const source = [
|
|
154
|
+
'-- @ns.a',
|
|
155
|
+
'CREATE TABLE t1 (',
|
|
156
|
+
' id SERIAL',
|
|
157
|
+
');',
|
|
158
|
+
'',
|
|
159
|
+
'-- @ns.b',
|
|
160
|
+
'CREATE TABLE t2 (',
|
|
161
|
+
' id SERIAL',
|
|
162
|
+
');',
|
|
163
|
+
].join('\n')
|
|
164
|
+
|
|
165
|
+
compile({
|
|
166
|
+
source,
|
|
167
|
+
filePath: 'multi.sql',
|
|
168
|
+
plugins: new Map([['ns', plugin]]),
|
|
169
|
+
statements: makeStatements([
|
|
170
|
+
{ kind: 'table', objectName: 't1', line: lineOf(source, 'CREATE TABLE t1') },
|
|
171
|
+
{ kind: 'table', objectName: 't2', line: lineOf(source, 'CREATE TABLE t2') },
|
|
172
|
+
]),
|
|
173
|
+
adapter: stubAdapter,
|
|
174
|
+
config: { dialect: 'postgres' },
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(capturedCtx).not.toBeNull()
|
|
178
|
+
expect(capturedCtx.fileTags).toHaveLength(2)
|
|
179
|
+
expect(capturedCtx.fileTags).toEqual(
|
|
180
|
+
expect.arrayContaining([
|
|
181
|
+
expect.objectContaining({ objectName: 't1' }),
|
|
182
|
+
expect.objectContaining({ objectName: 't2' }),
|
|
183
|
+
]),
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('populates CompilerContext.config with namespace-specific config from ProjectConfig', () => {
|
|
188
|
+
let capturedCtx: any = null
|
|
189
|
+
|
|
190
|
+
const plugin = makePlugin({
|
|
191
|
+
name: 'myns',
|
|
192
|
+
tags: { go: {} },
|
|
193
|
+
generateSQL: (ctx) => {
|
|
194
|
+
capturedCtx = ctx
|
|
195
|
+
return []
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const source = ['-- @myns.go', 'CREATE TABLE cfg_test (id SERIAL);'].join('\n')
|
|
200
|
+
|
|
201
|
+
const config: ResolvedConfig = {
|
|
202
|
+
dialect: 'postgres',
|
|
203
|
+
namespaces: {
|
|
204
|
+
myns: { outputFormat: 'json', verbose: true },
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
compile({
|
|
209
|
+
source,
|
|
210
|
+
filePath: 'cfg.sql',
|
|
211
|
+
plugins: new Map([['myns', plugin]]),
|
|
212
|
+
statements: makeStatements([{ kind: 'table', objectName: 'cfg_test', line: lineOf(source, 'CREATE') }]),
|
|
213
|
+
adapter: stubAdapter,
|
|
214
|
+
config,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(capturedCtx).not.toBeNull()
|
|
218
|
+
expect(capturedCtx.config).toEqual({ outputFormat: 'json', verbose: true })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('populates CompilerContext.namespaceTags with same-namespace tags on same object', () => {
|
|
222
|
+
let capturedCtx: any = null
|
|
223
|
+
|
|
224
|
+
const plugin = makePlugin({
|
|
225
|
+
name: 'multi',
|
|
226
|
+
tags: { first: {}, second: { args: [{ type: 'number' }] } },
|
|
227
|
+
generateSQL: (ctx) => {
|
|
228
|
+
if (ctx.tag.name === 'first') capturedCtx = ctx
|
|
229
|
+
return []
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const source = ['-- @multi.first', '-- @multi.second(42)', 'CREATE TABLE nstags (id SERIAL);'].join('\n')
|
|
234
|
+
|
|
235
|
+
compile({
|
|
236
|
+
source,
|
|
237
|
+
filePath: 'nstags.sql',
|
|
238
|
+
plugins: new Map([['multi', plugin]]),
|
|
239
|
+
statements: makeStatements([{ kind: 'table', objectName: 'nstags', line: lineOf(source, 'CREATE') }]),
|
|
240
|
+
adapter: stubAdapter,
|
|
241
|
+
config: { dialect: 'postgres' },
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
expect(capturedCtx).not.toBeNull()
|
|
245
|
+
expect(capturedCtx.namespaceTags).toHaveLength(2)
|
|
246
|
+
expect(capturedCtx.namespaceTags).toEqual(
|
|
247
|
+
expect.arrayContaining([expect.objectContaining({ tag: 'first' }), expect.objectContaining({ tag: 'second' })]),
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('correctly matches column tags when blank lines separate columns inside a table', () => {
|
|
252
|
+
const contexts: any[] = []
|
|
253
|
+
|
|
254
|
+
const plugin = makePlugin({
|
|
255
|
+
name: 'ns',
|
|
256
|
+
tags: { mark: {} },
|
|
257
|
+
generateSQL: (ctx) => {
|
|
258
|
+
contexts.push({ target: ctx.target, objectName: ctx.objectName, columnName: ctx.columnName })
|
|
259
|
+
return [{ sql: `-- marked ${ctx.columnName ?? ctx.objectName}` }]
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const source = [
|
|
264
|
+
'-- @ns.mark',
|
|
265
|
+
'CREATE TABLE items (',
|
|
266
|
+
' id SERIAL PRIMARY KEY,',
|
|
267
|
+
'',
|
|
268
|
+
' -- @ns.mark',
|
|
269
|
+
' name TEXT NOT NULL,',
|
|
270
|
+
'',
|
|
271
|
+
' -- @ns.mark',
|
|
272
|
+
' price INTEGER',
|
|
273
|
+
');',
|
|
274
|
+
].join('\n')
|
|
275
|
+
|
|
276
|
+
const _result = compile({
|
|
277
|
+
source,
|
|
278
|
+
filePath: 'blank-lines.sql',
|
|
279
|
+
plugins: new Map([['ns', plugin]]),
|
|
280
|
+
statements: makeStatements([
|
|
281
|
+
{
|
|
282
|
+
kind: 'table',
|
|
283
|
+
objectName: 'items',
|
|
284
|
+
line: lineOf(source, 'CREATE TABLE items'),
|
|
285
|
+
columns: [
|
|
286
|
+
{ name: 'id', dataType: 'serial', line: lineOf(source, 'id SERIAL'), raw: null },
|
|
287
|
+
{ name: 'name', dataType: 'text', line: lineOf(source, 'name TEXT'), raw: null },
|
|
288
|
+
{ name: 'price', dataType: 'integer', line: lineOf(source, 'price INTEGER'), raw: null },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
]),
|
|
292
|
+
adapter: stubAdapter,
|
|
293
|
+
config: { dialect: 'postgres' },
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// First tag is above CREATE TABLE — should be table-level
|
|
297
|
+
expect(contexts[0]).toEqual(expect.objectContaining({ target: 'table', objectName: 'items' }))
|
|
298
|
+
// Second tag (after blank line) should match 'name' column
|
|
299
|
+
expect(contexts[1]).toEqual(expect.objectContaining({ target: 'column', columnName: 'name' }))
|
|
300
|
+
// Third tag (after blank line) should match 'price' column
|
|
301
|
+
expect(contexts[2]).toEqual(expect.objectContaining({ target: 'column', columnName: 'price' }))
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('catches generator errors and reports in CompilerOutput.errors without crashing', () => {
|
|
305
|
+
const plugin = makePlugin({
|
|
306
|
+
name: 'err',
|
|
307
|
+
tags: { boom: {} },
|
|
308
|
+
generateSQL: () => {
|
|
309
|
+
throw new Error('generator exploded')
|
|
310
|
+
},
|
|
311
|
+
generateCode: () => {
|
|
312
|
+
throw new Error('codegen failed')
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const source = ['-- @err.boom', 'CREATE TABLE broken (id SERIAL);'].join('\n')
|
|
317
|
+
|
|
318
|
+
const result = compile({
|
|
319
|
+
source,
|
|
320
|
+
filePath: 'err.sql',
|
|
321
|
+
plugins: new Map([['err', plugin]]),
|
|
322
|
+
statements: makeStatements([{ kind: 'table', objectName: 'broken', line: lineOf(source, 'CREATE') }]),
|
|
323
|
+
adapter: stubAdapter,
|
|
324
|
+
config: { dialect: 'postgres' },
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(1)
|
|
328
|
+
expect(result.errors).toEqual(
|
|
329
|
+
expect.arrayContaining([
|
|
330
|
+
expect.objectContaining({ namespace: 'err', message: expect.stringContaining('generator exploded') }),
|
|
331
|
+
]),
|
|
332
|
+
)
|
|
333
|
+
// Should not have any outputs from the errored generators
|
|
334
|
+
expect(result.sqlOutputs).toHaveLength(0)
|
|
335
|
+
expect(result.codeOutputs).toHaveLength(0)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('returns empty outputs with no crash when plugins have no generators', () => {
|
|
339
|
+
const plugin = makePlugin({
|
|
340
|
+
name: 'passive',
|
|
341
|
+
tags: { info: {} },
|
|
342
|
+
// No generateSQL or generateCode
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
const source = ['-- @passive.info', 'CREATE TABLE inert (id SERIAL);'].join('\n')
|
|
346
|
+
|
|
347
|
+
const result = compile({
|
|
348
|
+
source,
|
|
349
|
+
filePath: 'inert.sql',
|
|
350
|
+
plugins: new Map([['passive', plugin]]),
|
|
351
|
+
statements: makeStatements([{ kind: 'table', objectName: 'inert', line: lineOf(source, 'CREATE') }]),
|
|
352
|
+
adapter: stubAdapter,
|
|
353
|
+
config: { dialect: 'postgres' },
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
expect(result.sqlOutputs).toHaveLength(0)
|
|
357
|
+
expect(result.codeOutputs).toHaveLength(0)
|
|
358
|
+
expect(result.errors).toHaveLength(0)
|
|
359
|
+
expect(result.sourceFile).toBe('inert.sql')
|
|
360
|
+
// mergedSql should be just the original source when no SQL generated
|
|
361
|
+
expect(result.mergedSql).toBe(source)
|
|
362
|
+
})
|
|
363
|
+
})
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
// Import the actual plugins to test their built-in lint rules
|
|
3
|
+
import auditPlugin from '../../../ns-audit/src/index.ts'
|
|
4
|
+
import rlsPlugin from '../../../ns-rls/src/index'
|
|
5
|
+
import validatePlugin from '../../../ns-validate/src/index'
|
|
6
|
+
import type { CompilerOutput, NamespacePlugin, ResolvedConfig } from '../compiler/types'
|
|
7
|
+
import { lint } from '../lint'
|
|
8
|
+
|
|
9
|
+
/** Helper to create a minimal CompilerOutput */
|
|
10
|
+
function makeOutput(overrides: Partial<CompilerOutput> = {}): CompilerOutput {
|
|
11
|
+
return {
|
|
12
|
+
sourceFile: 'schema.sql',
|
|
13
|
+
mergedSql: '',
|
|
14
|
+
sqlOutputs: [],
|
|
15
|
+
codeOutputs: [],
|
|
16
|
+
errors: [],
|
|
17
|
+
docsMeta: [],
|
|
18
|
+
fileTags: [],
|
|
19
|
+
...overrides,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('built-in lint rules', () => {
|
|
24
|
+
describe('audit.require-audit', () => {
|
|
25
|
+
it('flags tables without @audit tag', () => {
|
|
26
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
27
|
+
plugins.set('audit', auditPlugin)
|
|
28
|
+
|
|
29
|
+
const output = makeOutput({
|
|
30
|
+
fileTags: [
|
|
31
|
+
{
|
|
32
|
+
objectName: 'users',
|
|
33
|
+
target: 'table',
|
|
34
|
+
tags: [],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
objectName: 'orders',
|
|
38
|
+
target: 'table',
|
|
39
|
+
tags: [],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const results = lint([output], plugins, { dialect: 'postgres' })
|
|
45
|
+
expect(results).toHaveLength(2)
|
|
46
|
+
expect(results[0].ruleName).toBe('audit.require-audit')
|
|
47
|
+
expect(results[0].objectName).toBe('users')
|
|
48
|
+
expect(results[0].message).toContain('users')
|
|
49
|
+
expect(results[1].objectName).toBe('orders')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not flag tables that have @audit', () => {
|
|
53
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
54
|
+
plugins.set('audit', auditPlugin)
|
|
55
|
+
|
|
56
|
+
const output = makeOutput({
|
|
57
|
+
fileTags: [
|
|
58
|
+
{
|
|
59
|
+
objectName: 'users',
|
|
60
|
+
target: 'table',
|
|
61
|
+
tags: [{ namespace: 'audit', tag: null, args: {} }],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const results = lint([output], plugins, { dialect: 'postgres' })
|
|
67
|
+
expect(results).toHaveLength(0)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not flag tables that have @audit with $self tag', () => {
|
|
71
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
72
|
+
plugins.set('audit', auditPlugin)
|
|
73
|
+
|
|
74
|
+
const output = makeOutput({
|
|
75
|
+
fileTags: [
|
|
76
|
+
{
|
|
77
|
+
objectName: 'users',
|
|
78
|
+
target: 'table',
|
|
79
|
+
tags: [{ namespace: 'audit', tag: '$self', args: {} }],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const results = lint([output], plugins, { dialect: 'postgres' })
|
|
85
|
+
expect(results).toHaveLength(0)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('ignores column-level fileTags', () => {
|
|
89
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
90
|
+
plugins.set('audit', auditPlugin)
|
|
91
|
+
|
|
92
|
+
const output = makeOutput({
|
|
93
|
+
fileTags: [
|
|
94
|
+
{
|
|
95
|
+
objectName: 'users.email',
|
|
96
|
+
target: 'column',
|
|
97
|
+
tags: [],
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const results = lint([output], plugins, { dialect: 'postgres' })
|
|
103
|
+
expect(results).toHaveLength(0)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('rls.require-policy', () => {
|
|
108
|
+
it('flags tables without @rls tag', () => {
|
|
109
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
110
|
+
plugins.set('rls', rlsPlugin)
|
|
111
|
+
|
|
112
|
+
const output = makeOutput({
|
|
113
|
+
fileTags: [
|
|
114
|
+
{
|
|
115
|
+
objectName: 'users',
|
|
116
|
+
target: 'table',
|
|
117
|
+
tags: [],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const results = lint([output], plugins, { dialect: 'postgres' })
|
|
123
|
+
expect(results).toHaveLength(1)
|
|
124
|
+
expect(results[0].ruleName).toBe('rls.require-policy')
|
|
125
|
+
expect(results[0].message).toContain('users')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('does not flag tables with @rls', () => {
|
|
129
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
130
|
+
plugins.set('rls', rlsPlugin)
|
|
131
|
+
|
|
132
|
+
const output = makeOutput({
|
|
133
|
+
fileTags: [
|
|
134
|
+
{
|
|
135
|
+
objectName: 'users',
|
|
136
|
+
target: 'table',
|
|
137
|
+
tags: [{ namespace: 'rls', tag: null, args: {} }],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const results = lint([output], plugins, { dialect: 'postgres' })
|
|
143
|
+
expect(results).toHaveLength(0)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('validate.require-pk', () => {
|
|
148
|
+
it('flags tables without a primary key in SQL', () => {
|
|
149
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
150
|
+
plugins.set('validate', validatePlugin)
|
|
151
|
+
|
|
152
|
+
const output = makeOutput({
|
|
153
|
+
mergedSql: `CREATE TABLE users (
|
|
154
|
+
name TEXT NOT NULL,
|
|
155
|
+
email TEXT
|
|
156
|
+
);`,
|
|
157
|
+
fileTags: [
|
|
158
|
+
{
|
|
159
|
+
objectName: 'users',
|
|
160
|
+
target: 'table',
|
|
161
|
+
tags: [],
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const results = lint([output], plugins, { dialect: 'postgres' })
|
|
167
|
+
expect(results).toHaveLength(1)
|
|
168
|
+
expect(results[0].ruleName).toBe('validate.require-pk')
|
|
169
|
+
expect(results[0].message).toContain('users')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('does not flag tables with a primary key', () => {
|
|
173
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
174
|
+
plugins.set('validate', validatePlugin)
|
|
175
|
+
|
|
176
|
+
const output = makeOutput({
|
|
177
|
+
mergedSql: `CREATE TABLE users (
|
|
178
|
+
id BIGSERIAL PRIMARY KEY,
|
|
179
|
+
name TEXT NOT NULL
|
|
180
|
+
);`,
|
|
181
|
+
fileTags: [
|
|
182
|
+
{
|
|
183
|
+
objectName: 'users',
|
|
184
|
+
target: 'table',
|
|
185
|
+
tags: [],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const results = lint([output], plugins, { dialect: 'postgres' })
|
|
191
|
+
expect(results).toHaveLength(0)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('end-to-end: multiple plugins + config + ignore', () => {
|
|
196
|
+
it('applies all rules, config overrides, and @lint.ignore together', () => {
|
|
197
|
+
const plugins = new Map<string, NamespacePlugin>()
|
|
198
|
+
plugins.set('audit', auditPlugin)
|
|
199
|
+
plugins.set('rls', rlsPlugin)
|
|
200
|
+
|
|
201
|
+
const config: ResolvedConfig = {
|
|
202
|
+
dialect: 'postgres',
|
|
203
|
+
lint: {
|
|
204
|
+
rules: {
|
|
205
|
+
'audit.require-audit': 'error', // upgrade from warn
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const output = makeOutput({
|
|
211
|
+
fileTags: [
|
|
212
|
+
{
|
|
213
|
+
objectName: 'users',
|
|
214
|
+
target: 'table',
|
|
215
|
+
tags: [],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
objectName: 'staging_data',
|
|
219
|
+
target: 'table',
|
|
220
|
+
tags: [
|
|
221
|
+
{ namespace: 'lint', tag: 'ignore', args: ['audit.require-audit', 'Staging table'] },
|
|
222
|
+
{ namespace: 'lint', tag: 'ignore', args: ['rls.require-policy', 'Internal only'] },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const results = lint([output], plugins, config)
|
|
229
|
+
|
|
230
|
+
// users: audit.require-audit (error), rls.require-policy (warn)
|
|
231
|
+
// staging_data: audit.require-audit (skip), rls.require-policy (skip)
|
|
232
|
+
expect(results).toHaveLength(4)
|
|
233
|
+
|
|
234
|
+
const usersAudit = results.find((r) => r.objectName === 'users' && r.ruleName === 'audit.require-audit')
|
|
235
|
+
expect(usersAudit!.severity).toBe('error')
|
|
236
|
+
|
|
237
|
+
const usersRls = results.find((r) => r.objectName === 'users' && r.ruleName === 'rls.require-policy')
|
|
238
|
+
expect(usersRls!.severity).toBe('warn')
|
|
239
|
+
|
|
240
|
+
const stagingAudit = results.find((r) => r.objectName === 'staging_data' && r.ruleName === 'audit.require-audit')
|
|
241
|
+
expect(stagingAudit!.severity).toBe('skip')
|
|
242
|
+
expect(stagingAudit!.ignoreReason).toBe('Staging table')
|
|
243
|
+
|
|
244
|
+
const stagingRls = results.find((r) => r.objectName === 'staging_data' && r.ruleName === 'rls.require-policy')
|
|
245
|
+
expect(stagingRls!.severity).toBe('skip')
|
|
246
|
+
expect(stagingRls!.ignoreReason).toBe('Internal only')
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
})
|