Skip to content

Commit dc2041d

Browse files
committed
Support ranges
1 parent b7513b6 commit dc2041d

File tree

11 files changed

+589
-485
lines changed

11 files changed

+589
-485
lines changed

modules/module-postgres/src/types/cache.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ SELECT oid, t.typtype,
3232
FROM pg_attribute a
3333
WHERE a.attrelid = t.typrelid)
3434
)
35+
WHEN 'r' THEN json_build_object('inner', (SELECT rngsubtype FROM pg_range WHERE rngtypid = t.oid))
36+
WHEN 'm' THEN json_build_object('inner', (SELECT rngsubtype FROM pg_range WHERE rngmultitypid = t.oid))
3537
ELSE NULL
3638
END AS desc
3739
FROM pg_type t
@@ -59,9 +61,11 @@ WHERE t.oid = ANY($1)
5961

6062
if (!this.registry.knows(oid)) {
6163
// This type is an array of another custom type.
64+
const inner = Number(element_type);
65+
requireType(inner);
6266
this.registry.set(oid, {
6367
type: 'array',
64-
innerId: Number(element_type),
68+
innerId: inner,
6569
separatorCharCode: (delim as string).charCodeAt(0),
6670
sqliteType: () => 'text' // Since it's JSON
6771
});
@@ -89,6 +93,15 @@ WHERE t.oid = ANY($1)
8993
this.registry.setDomainType(oid, inner);
9094
requireType(inner);
9195
break;
96+
case 'r':
97+
case 'm': {
98+
const inner = Number(desc.inner);
99+
this.registry.set(oid, {
100+
type: row.typtype == 'r' ? 'range' : 'multirange',
101+
innerId: inner,
102+
sqliteType: () => 'text' // Since it's JSON
103+
});
104+
}
92105
}
93106
}
94107

@@ -110,7 +123,6 @@ JOIN pg_namespace tn ON tn.oid = t.typnamespace
110123
WHERE a.attnum > 0
111124
AND NOT a.attisdropped
112125
AND cn.nspname = $1
113-
AND tn.nspname NOT IN ('pg_catalog', 'information_schema');
114126
`;
115127

116128
const query = await this.pool.query({ statement: sql, params: [{ type: 'varchar', value: schema }] });

modules/module-postgres/src/types/registry.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,17 @@ interface CompositeType extends BaseType {
4949
members: { name: string; typeId: number }[];
5050
}
5151

52-
type KnownType = BuiltinType | ArrayType | DomainType | DomainType | CompositeType;
52+
/**
53+
* A type created with `CREATE TYPE AS RANGE`.
54+
*
55+
* Ranges are represented as {@link pgwire.Range}. Multiranges are represented as arrays thereof.
56+
*/
57+
interface RangeType extends BaseType {
58+
type: 'range' | 'multirange';
59+
innerId: number;
60+
}
61+
62+
type KnownType = BuiltinType | ArrayType | DomainType | DomainType | CompositeType | RangeType;
5363

5464
interface UnknownType extends BaseType {
5565
type: 'unknown';
@@ -199,23 +209,13 @@ export class CustomTypeRegistry {
199209
case 'composite': {
200210
const parsed: [string, any][] = [];
201211

202-
pgwire.decodeSequence({
203-
source: raw,
204-
delimiters: pgwire.COMPOSITE_DELIMITERS,
205-
listener: {
206-
onValue: (raw) => {
207-
const nextMember = resolved.members[parsed.length];
208-
if (nextMember) {
209-
const value = raw == null ? null : this.decodeWithCustomTypes(raw, nextMember.typeId);
210-
parsed.push([nextMember.name, value]);
211-
}
212-
},
213-
// These are only used for nested arrays
214-
onStructureStart: () => {},
215-
onStructureEnd: () => {}
212+
new pgwire.StructureParser(raw).parseComposite((raw) => {
213+
const nextMember = resolved.members[parsed.length];
214+
if (nextMember) {
215+
const value = raw == null ? null : this.decodeWithCustomTypes(raw, nextMember.typeId);
216+
parsed.push([nextMember.name, value]);
216217
}
217218
});
218-
219219
return Object.fromEntries(parsed);
220220
}
221221
case 'array': {
@@ -234,12 +234,15 @@ export class CustomTypeRegistry {
234234
}
235235
}
236236

237-
return pgwire.decodeArray({
238-
source: raw,
239-
decodeElement: (source) => this.decodeWithCustomTypes(source, innerId),
240-
delimiterCharCode: resolved.separatorCharCode
241-
});
237+
return new pgwire.StructureParser(raw).parseArray(
238+
(source) => this.decodeWithCustomTypes(source, innerId),
239+
resolved.separatorCharCode
240+
);
242241
}
242+
case 'range':
243+
return new pgwire.StructureParser(raw).parseRange((s) => this.decodeWithCustomTypes(s, resolved.innerId));
244+
case 'multirange':
245+
return new pgwire.StructureParser(raw).parseMultiRange((s) => this.decodeWithCustomTypes(s, resolved.innerId));
243246
}
244247
}
245248

modules/module-postgres/test/src/pg_test.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -478,13 +478,16 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12'
478478
await db.query(`CREATE DOMAIN rating_value AS FLOAT CHECK (VALUE BETWEEN 0 AND 5);`);
479479
await db.query(`CREATE TYPE composite AS (foo rating_value[], bar TEXT);`);
480480
await db.query(`CREATE TYPE nested_composite AS (a BOOLEAN, b composite);`);
481+
await db.query(`CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')`);
481482

482483
await db.query(`CREATE TABLE test_custom(
483484
id serial primary key,
484485
rating rating_value,
485486
composite composite,
486487
nested_composite nested_composite,
487-
boxes box[]
488+
boxes box[],
489+
mood mood,
490+
ranges int4multirange[]
488491
);`);
489492

490493
const slotName = 'test_slot';
@@ -501,12 +504,14 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12'
501504

502505
await db.query(`
503506
INSERT INTO test_custom
504-
(rating, composite, nested_composite, boxes)
507+
(rating, composite, nested_composite, boxes, mood, ranges)
505508
VALUES (
506509
1,
507510
(ARRAY[2,3], 'bar'),
508511
(TRUE, (ARRAY[2,3], 'bar')),
509-
ARRAY[box(point '(1,2)', point '(3,4)'), box(point '(5, 6)', point '(7,8)')]
512+
ARRAY[box(point '(1,2)', point '(3,4)'), box(point '(5, 6)', point '(7,8)')],
513+
'happy',
514+
ARRAY[int4multirange(int4range(2, 4), int4range(5, 7, '(]'))]::int4multirange[]
510515
);
511516
`);
512517

@@ -527,15 +532,24 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12'
527532
rating: '1',
528533
composite: '("{2,3}",bar)',
529534
nested_composite: '(t,"(""{2,3}"",bar)")',
530-
boxes: '["(3","4)","(1","2);(7","8)","(5","6)"]'
535+
boxes: '["(3","4)","(1","2);(7","8)","(5","6)"]',
536+
mood: 'happy',
537+
ranges: '{"{[2,4),[6,8)}"}'
531538
});
532539

533540
const newFormat = applyRowContext(transformed, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS));
534541
expect(newFormat).toMatchObject({
535542
rating: 1,
536543
composite: '{"foo":[2.0,3.0],"bar":"bar"}',
537544
nested_composite: '{"a":1,"b":{"foo":[2.0,3.0],"bar":"bar"}}',
538-
boxes: JSON.stringify(['(3,4),(1,2)', '(7,8),(5,6)'])
545+
boxes: JSON.stringify(['(3,4),(1,2)', '(7,8),(5,6)']),
546+
mood: 'happy',
547+
ranges: JSON.stringify([
548+
[
549+
{ lower: 2, upper: 4, lower_exclusive: 0, upper_exclusive: 1 },
550+
{ lower: 6, upper: 8, lower_exclusive: 0, upper_exclusive: 1 }
551+
]
552+
])
539553
});
540554
} finally {
541555
await db.end();

modules/module-postgres/test/src/types/registry.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,29 @@ describe('custom type registry', () => {
121121
// SELECT ROW(ARRAY[(FALSE,2)]::c2[])::c3;
122122
checkResult('("{""(f,2)""}")', 1339, '("{""(f,2)""}")', '{"c":[{"a":0,"b":2}]}');
123123
});
124+
125+
test('range', () => {
126+
registry.set(1337, {
127+
type: 'range',
128+
sqliteType: () => 'text',
129+
innerId: PgTypeOid.INT2
130+
});
131+
132+
checkResult('[1,2]', 1337, '[1,2]', '{"lower":1,"upper":2,"lower_exclusive":0,"upper_exclusive":0}');
133+
});
134+
135+
test('multirange', () => {
136+
registry.set(1337, {
137+
type: 'multirange',
138+
sqliteType: () => 'text',
139+
innerId: PgTypeOid.INT2
140+
});
141+
142+
checkResult(
143+
'{[1,2),[3,4)}',
144+
1337,
145+
'{[1,2),[3,4)}',
146+
'[{"lower":1,"upper":2,"lower_exclusive":0,"upper_exclusive":1},{"lower":3,"upper":4,"lower_exclusive":0,"upper_exclusive":1}]'
147+
);
148+
});
124149
});

packages/jpgwire/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ export * from './certs.js';
33
export * from './util.js';
44
export * from './metrics.js';
55
export * from './pgwire_types.js';
6-
export * from './sequence_tokenizer.js';
6+
export * from './structure_parser.js';

packages/jpgwire/src/pgwire_types.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,7 @@
33
import { JsonContainer } from '@powersync/service-jsonbig';
44
import { CustomSqliteValue, TimeValue, type DatabaseInputValue } from '@powersync/service-sync-rules';
55
import { dateToSqlite, lsnMakeComparable, timestampToSqlite, timestamptzToSqlite } from './util.js';
6-
import {
7-
arrayDelimiters,
8-
CHAR_CODE_COMMA,
9-
CHAR_CODE_LEFT_BRACE,
10-
CHAR_CODE_RIGHT_BRACE,
11-
decodeArray,
12-
decodeSequence,
13-
Delimiters,
14-
SequenceListener
15-
} from './sequence_tokenizer.js';
6+
import { StructureParser } from './structure_parser.js';
167

178
export enum PgTypeOid {
189
TEXT = 25,
@@ -165,10 +156,7 @@ export class PgType {
165156

166157
static _decodeArray(text: string, elemTypeOid: number): DatabaseInputValue[] {
167158
text = text.replace(/^\[.+=/, ''); // skip dimensions
168-
return decodeArray({
169-
source: text,
170-
decodeElement: (raw) => PgType.decode(raw, elemTypeOid)
171-
});
159+
return new StructureParser(text).parseArray((raw) => (raw == null ? null : PgType.decode(raw, elemTypeOid)));
172160
}
173161

174162
static _decodeBytea(text: string): Uint8Array {

0 commit comments

Comments
 (0)