Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(typegen): retain intersection types #389

Merged
merged 11 commits into from
Apr 16, 2022
59 changes: 59 additions & 0 deletions packages/typegen/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,65 @@ module.exports.default = {
}
```

## Enhancing Return Types

Typegen is designed to output types only to the degree it's certain they are correct.

Let's say in a complex query it can determine that a specific column will return a `string`, but isn't sure if it is also nullable, it will extract the type as `{ column: string | null }`, just to be on the safe side. When it encounters columns where it is unable to even determine the basic type, i.e. `json` columns, it will return :shrug: (Ok, actually the typescript equivalent, which is `unknown`).

In these cases you likely know more about the actual return type than typegen and you might feel the urge to overwrite the types.
Yet you shouldn't touch generated code, as your changes will be removed again on the next run.

Instead what you should do is add (one or more) intersection types to the sql literal, specifying the columns where you want to help typegen out by increasing specificity. The resulting type will be a combination of the extracted types and your enhancements.
Check out the [typescript docs on intersection types](https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types) to learn more.

Imagine this is your code after running typegen.
```typescript
sql<queries.ExtractedResult>`select string_col, json_col from table`

export declare namespace queries {
// Generated by @slonik/typegen

/** - query: `select string_col, json_col from table` */
export interface TestTable {
/** column: `example_test.table.string_col`, regtype: `character_varying` */
string_col: string | null,
/** column: `example_test.table.json_col`, regtype: `jsonb` */
json_col: unkown
}
}
```

You can enhance the return type like this:

```typescript
sql<queries.ExtractedResult & { json_col: string[] }>`[query]`
```
\- or, if you prefer -
```typescript
interface EnhancedResult {
json_col: string[]
}
sql<queries.ExtractedResult & EnhancedResult>`[query]`
```

Either way the resulting type will be this:

```typescript
type ResultingType = {
string_col: string | null,
json_col: string[]
}
```

**On subsequent runs typegen will only update the first intersection type and leave all following intersections untouched**.

This also means you can make the column `string_col` non-nullable by intersecting it with `{ string_col: string }`.
mmkal marked this conversation as resolved.
Show resolved Hide resolved

Note that you can't completely change a property type (say from `string` to `number`) this way.
This is by design, because if you could, a change in the underlying table might cause typegen to detect a new type, which would be ignored, had you overwritten it. This would cause type changes to go unnoticed and we can't have that.
With intersections, the resulting property will be of type `never`, when an underlying column type changes. This will alert you to the change, so you can update your manual enhancements.

## Examples

[The tests](./test) and [corresponding fixtures](./test/fixtures) are a good starting point to see what the code-generator will do.
Expand Down
54 changes: 39 additions & 15 deletions packages/typegen/src/write/inline.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import * as path from 'path'

import * as lodash from 'lodash'
import type * as ts from 'typescript'

import {TaggedQuery} from '../types'
import {relativeUnixPath} from '../util'
import {tsPrettify} from './prettify'
import type * as ts from 'typescript'
import * as path from 'path'
import {queryInterfaces} from './typescript'
import {WriteFile} from '.'

// todo: pg-protocol parseError adds all the actually useful information
// to fields which don't show up in error messages. make a library which patches it to include relevant info.

const queryNamespace = 'queries' // todo: at some point we might want to make this configurable
janpaepke marked this conversation as resolved.
Show resolved Hide resolved

export const defaultGetQueriesModule = (filepath: string) => filepath

export interface WriteTSFileOptions {
Expand All @@ -34,7 +38,7 @@ export function getFileWriter({getQueriesModulePath = defaultGetQueriesModule, w

const edits: Array<Edit> = []

visit(sourceFile)
visitRecursive(sourceFile)

const destPath = getQueriesModulePath(file)
if (destPath === file) {
Expand All @@ -48,7 +52,7 @@ export function getFileWriter({getQueriesModulePath = defaultGetQueriesModule, w
await writeFile(destPath, content)

const importPath = relativeUnixPath(destPath, path.dirname(file))
const importStatement = `import * as queries from './${importPath.replace(/\.(js|ts|tsx)$/, '')}'`
const importStatement = `import * as ${queryNamespace} from './${importPath.replace(/\.(js|ts|tsx)$/, '')}'`

const importExists =
originalSource.includes(importStatement) ||
Expand All @@ -69,31 +73,51 @@ export function getFileWriter({getQueriesModulePath = defaultGetQueriesModule, w

await writeFile(file, newSource)

function visit(node: ts.Node) {
if (ts.isModuleDeclaration(node) && node.name.getText() === 'queries') {
function visitRecursive(node: ts.Node) {
if (ts.isModuleDeclaration(node) && node.name.getText() === queryNamespace) {
// remove old import(s) (will get re-added later)
edits.push({
start: node.getStart(sourceFile),
end: node.getEnd(),
replacement: '',
})
return
}

if (ts.isTaggedTemplateExpression(node)) {
const isSqlIdentifier = (n: ts.Node) => ts.isIdentifier(n) && n.getText() === 'sql'
const sqlPropertyAccessor = ts.isPropertyAccessExpression(node.tag) && isSqlIdentifier(node.tag.name)
if (isSqlIdentifier(node.tag) || sqlPropertyAccessor) {
const match = group.find(q => q.text === node.getFullText())
if (match) {
const isSqlIdentifier = (e: ts.Node) => ts.isIdentifier(e) && e.getText() === 'sql'
const isSqlPropertyAccessor = (e: ts.Expression) => ts.isPropertyAccessExpression(e) && isSqlIdentifier(e.name)
if (!isSqlIdentifier(node.tag) && !isSqlPropertyAccessor(node.tag)) {
return
}
const matchingQuery = group.find(q => q.text === node.getFullText())
if (!matchingQuery) {
return
}
const typeReference = `${queryNamespace}.${matchingQuery.tag}`
if (node.typeArguments && node.typeArguments.length === 1) {
// existing type definitions
const [typeNode] = node.typeArguments
if (ts.isIntersectionTypeNode(typeNode)) {
// preserve intersection types
const [firstArg] = typeNode.types // Always overwrite the first type in the intersection, leave all subsequent ones alone.
edits.push({
start: node.tag.getStart(sourceFile),
end: node.template.getStart(sourceFile),
replacement: `${node.tag.getText()}<queries.${match.tag}>`,
start: firstArg.getStart(sourceFile),
end: firstArg.getEnd(),
replacement: typeReference,
})
return
}
}
// default: replace complete tag to add/overwrite type arguments
edits.push({
start: node.tag.getStart(sourceFile),
end: node.template.getStart(sourceFile),
replacement: `${node.tag.getText()}<${typeReference}>`,
})
}

ts.forEachChild(node, visit)
ts.forEachChild(node, visitRecursive)
}
}
}
Expand Down
115 changes: 115 additions & 0 deletions packages/typegen/test/inline-tag-modification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as fsSyncer from 'fs-syncer'

import * as typegen from '../src'
import {getHelper} from './helper'

export const {typegenOptions, logger, poolHelper: helper} = getHelper({__filename})

beforeEach(async () => {
await helper.pool.query(helper.sql`
create table test_table(foo int not null, bar text);
`)
})

const createInput = (existingTag: string = '') => `
import {sql, createPool} from 'slonik'

export default () => {
const pool = createPool('...connection string...')
return pool.query(sql${existingTag}\`select foo, bar from test_table\`)
}
`
const createSnapshot = (resultingTag: string) => `
"---
index.ts: |-
${createInput(resultingTag).trim()}

export declare namespace queries {
// Generated by @slonik/typegen

/** - query: \`select foo, bar from test_table\` */
export interface TestTable {
/** column: \`inline_tag_modification_test.test_table.foo\`, not null: \`true\`, regtype: \`integer\` */
foo: number

/** column: \`inline_tag_modification_test.test_table.bar\`, regtype: \`text\` */
bar: string | null
}
}
"
`

const process = async (input: string = '') => {
const syncer = fsSyncer.jestFixture({
targetState: {'index.ts': input},
})
syncer.sync()
await typegen.generate(typegenOptions(syncer.baseDir))
return syncer.yaml()
}

const checkModification = async (existingType: string | undefined, resultingType: string) => {
const input = createInput(existingType === undefined ? '' : `<${existingType}>`)
const result = createSnapshot(`<${resultingType}>`)
const processed = await process(input)
expect(processed).toMatchInlineSnapshot(result)
}

describe('inline tag modification', () => {
test('add tag', () => checkModification(undefined, 'queries.TestTable'))
test('overwrite existing', async () => {
// running sequentially to avoid overlapping fixtures
await checkModification('{col: string}', 'queries.TestTable')
await checkModification('queries.TestTable', 'queries.TestTable')
await checkModification('queries.TestTable | Other', 'queries.TestTable')
await checkModification("Omit<queries.TestTable, 'foo'>", 'queries.TestTable')
})
test('preserve intersections', async () => {
// running sequentially to avoid overlapping fixtures
await checkModification('queries.TestTable & {col: string}', 'queries.TestTable & {col: string}')
await checkModification('queries.TestTable & Other', 'queries.TestTable & Other')
await checkModification(
'queries.TestTable & One & Two & {col: string}',
'queries.TestTable & One & Two & {col: string}',
)
await checkModification('{col: string} & Other', 'queries.TestTable & Other') // we can't tell if the first intersection type is generated, so it will always be overwritten
})

test('high-level', async () => {
const syncer = fsSyncer.jestFixture({
targetState: {
'index.ts': `
import {sql} from 'slonik'

export default sql<{} & {bar: string}>\`select foo, bar from test_table\`
`,
},
})

syncer.sync()

await typegen.generate(typegenOptions(syncer.baseDir))

expect(syncer.yaml()).toMatchInlineSnapshot(`
"---
index.ts: |-
import {sql} from 'slonik'

export default sql<queries.TestTable & {bar: string}>\`select foo, bar from test_table\`

export declare namespace queries {
// Generated by @slonik/typegen

/** - query: \`select foo, bar from test_table\` */
export interface TestTable {
/** column: \`inline_tag_modification_test.test_table.foo\`, not null: \`true\`, regtype: \`integer\` */
foo: number

/** column: \`inline_tag_modification_test.test_table.bar\`, regtype: \`text\` */
bar: string | null
}
}
"
`)
})
})