Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 9be7751

Browse files
authored
feat: Add transformer to ShapeStreamOption. (#3105)
`messageParser` will call `transformer` with the complete `message.value`. Can be used to convert object keys to camelCase. Possible solution to: #2904. This is an alternative to #3104 that would make it an option on the ShapeStreamOptions, so it can be exposed to the `useShape` hook without extra work.
1 parent 8623e73 commit 9be7751

File tree

7 files changed

+139
-3
lines changed

7 files changed

+139
-3
lines changed

.changeset/bright-fishes-attack.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@electric-sql/client": patch
3+
"@electric-sql/react": patch
4+
---
5+
6+
Add `transformer` function to `ShapeStreamOptions` to support transforms like camelCase keys.

packages/react-hooks/src/react-hooks.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export async function preloadShape<T extends Row<unknown> = Row>(
2525

2626
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2727
function sortObjectKeys(obj: any): any {
28+
if (typeof obj === `function`) return Function.prototype.toString.call(obj)
2829
if (typeof obj !== `object` || obj === null) return obj
2930

3031
if (Array.isArray(obj)) {

packages/react-hooks/test/react-hooks.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,47 @@ describe(`sortedOptionsHash`, () => {
4848
expect(hash1).not.toEqual(hash2)
4949
}
5050
)
51+
bareIt(`should create same hash with identical transformer function`, () => {
52+
const hash1 = sortedOptionsHash({
53+
url: `http://whatever`,
54+
params: {
55+
table: `foo`,
56+
},
57+
offset: `-1`,
58+
transformer: (r) => ({ ...r, test: 1 }),
59+
})
60+
const hash2 = sortedOptionsHash({
61+
offset: `-1`,
62+
params: {
63+
table: `foo`,
64+
},
65+
transformer: (r) => ({ ...r, test: 1 }),
66+
url: `http://whatever`,
67+
})
68+
expect(hash1).toEqual(hash2)
69+
})
70+
bareIt(
71+
`should create different hash with different transformer functions`,
72+
() => {
73+
const hash1 = sortedOptionsHash({
74+
url: `http://whatever`,
75+
params: {
76+
table: `foo`,
77+
},
78+
offset: `-1`,
79+
transformer: (r) => ({ ...r, test: 1 }),
80+
})
81+
const hash2 = sortedOptionsHash({
82+
offset: `-1`,
83+
params: {
84+
table: `foo`,
85+
},
86+
transformer: (r) => ({ ...r, test: 2 }),
87+
url: `http://whatever`,
88+
})
89+
expect(hash1).not.toEqual(hash2)
90+
}
91+
)
5192
})
5293

5394
describe(`useShape`, () => {

packages/typescript-client/src/client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
MaybePromise,
77
GetExtensions,
88
} from './types'
9-
import { MessageParser, Parser } from './parser'
9+
import { MessageParser, Parser, TransformFunction } from './parser'
1010
import { getOffset, isUpToDateMessage } from './helpers'
1111
import {
1212
FetchError,
@@ -259,6 +259,7 @@ export interface ShapeStreamOptions<T = never> {
259259
fetchClient?: typeof fetch
260260
backoffOptions?: BackoffOptions
261261
parser?: Parser<T>
262+
transformer?: TransformFunction<T>
262263

263264
/**
264265
* A function for handling shapestream errors.
@@ -379,7 +380,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
379380
this.#lastOffset = this.options.offset ?? `-1`
380381
this.#liveCacheBuster = ``
381382
this.#shapeHandle = this.options.handle
382-
this.#messageParser = new MessageParser<T>(options.parser)
383+
this.#messageParser = new MessageParser<T>(
384+
options.parser,
385+
options.transformer
386+
)
383387
this.#onError = this.options.onError
384388

385389
const baseFetchClient =

packages/typescript-client/src/parser.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export type Parser<Extensions = never> = {
1919
[key: string]: ParseFunction<Extensions>
2020
}
2121

22+
export type TransformFunction<Extensions = never> = (
23+
message: Row<Extensions>
24+
) => Row<Extensions>
25+
2226
const parseNumber = (value: string) => Number(value)
2327
const parseBool = (value: string) => value === `true` || value === `t`
2428
const parseBigInt = (value: string) => BigInt(value)
@@ -94,11 +98,16 @@ export function pgArrayParser<Extensions>(
9498

9599
export class MessageParser<T extends Row<unknown>> {
96100
private parser: Parser<GetExtensions<T>>
97-
constructor(parser?: Parser<GetExtensions<T>>) {
101+
private transformer?: TransformFunction<GetExtensions<T>>
102+
constructor(
103+
parser?: Parser<GetExtensions<T>>,
104+
transformer?: TransformFunction<GetExtensions<T>>
105+
) {
98106
// Merge the provided parser with the default parser
99107
// to use the provided parser whenever defined
100108
// and otherwise fall back to the default parser
101109
this.parser = { ...defaultParser, ...parser }
110+
this.transformer = transformer
102111
}
103112

104113
parse<Result>(messages: string, schema: Schema): Result {
@@ -118,6 +127,8 @@ export class MessageParser<T extends Row<unknown>> {
118127
Object.keys(row).forEach((key) => {
119128
row[key] = this.parseRow(key, row[key] as NullableToken, schema)
120129
})
130+
131+
if (this.transformer) value = this.transformer(value)
121132
}
122133
return value
123134
}) as Result

packages/typescript-client/test/client.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { Message, Row, ChangeMessage } from '../src/types'
1313
import { MissingHeadersError } from '../src/error'
1414
import { resolveValue } from '../src'
15+
import { TransformFunction } from '../src/parser'
1516

1617
const BASE_URL = inject(`baseUrl`)
1718

@@ -119,6 +120,38 @@ describe.for(fetchAndSse)(
119120
expect(shape.lastSynced()).toBeLessThanOrEqual(Date.now() - start)
120121
})
121122

123+
it(`should transform record with transformer function`, async ({
124+
issuesTableUrl,
125+
insertIssues,
126+
aborter,
127+
}) => {
128+
const [id] = await insertIssues({ title: `test title` })
129+
130+
// transformer example: uppercase keys
131+
const uppercaseKeys: TransformFunction = (row) =>
132+
Object.fromEntries(
133+
Object.entries(row).map(([k, v]) => [k.toUpperCase(), v])
134+
)
135+
136+
const shapeStream = new ShapeStream({
137+
url: `${BASE_URL}/v1/shape`,
138+
params: {
139+
table: issuesTableUrl,
140+
},
141+
signal: aborter.signal,
142+
experimentalLiveSse,
143+
transformer: uppercaseKeys,
144+
})
145+
146+
const shape = new Shape(shapeStream)
147+
148+
const rows = await new Promise((resolve) => {
149+
shape.subscribe(({ rows }) => resolve(rows))
150+
})
151+
152+
expect(rows).toEqual([{ ID: id, TITLE: `test title`, PRIORITY: 10 }])
153+
})
154+
122155
it(`should continually sync a shape/table`, async ({
123156
issuesTableUrl,
124157
insertIssues,

website/docs/api/clients/typescript.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,12 @@ export interface ShapeStreamOptions<T = never> {
238238
*/
239239
parser?: Parser<T>
240240

241+
/**
242+
* A function to transform the Message value before emitting to subscribers.
243+
* This can be used to camelCase keys or rename fields.
244+
*/
245+
transformer?: TransformFunction<T>
246+
241247
/**
242248
* A function for handling errors.
243249
* This is optional, when it is not provided any shapestream errors will be thrown.
@@ -386,6 +392,40 @@ shape.subscribe((data) => {
386392
})
387393
```
388394

395+
**Transformer**
396+
397+
While the parser operates on individual fields, the transformer allows you to modify the entire record after the parser has run.
398+
399+
This can be used to convert field names to camelCase or rename fields.
400+
401+
```ts
402+
type CustomRow = {
403+
id: number
404+
postTitle: string // post_title in database
405+
createdAt: Date // created_at in database
406+
}
407+
408+
// transformer example: camelCaseKeys
409+
const toCamelCase = (str: string) =>
410+
str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
411+
412+
const camelCaseKeys: TransformFunction = (row) =>
413+
Object.fromEntries(Object.entries(row).map(([k, v]) => [toCamelCase(k), v]))
414+
415+
const stream = new ShapeStream<CustomRow>({
416+
url: "http://localhost:3000/v1/shape",
417+
params: {
418+
table: "posts",
419+
},
420+
transformer: camelCaseKeys,
421+
})
422+
423+
const shape = new Shape(stream)
424+
shape.subscribe((data) => {
425+
console.log(Object.keys(data)) // [id, postTitle, createdAt]
426+
})
427+
```
428+
389429
#### Replica full
390430

391431
By default Electric sends the modified columns in an update message, not the complete row. To be specific:

0 commit comments

Comments
 (0)