@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.
@@ -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
+ })