Json object aggregate

In a multilingual app, I am using relational queries (but I could also just work with normal selects) to retrieve rows and their related translation strings. My schema has a bunch of tables like so:
export const projects = pgTable('projects', {
id: nanoid('id').default(generateNanoid()).primaryKey(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
publishedAt: timestamp('published_at', { withTimezone: true }),
typeId: integer('type_id').references(() => projectTypes.id, {
onDelete: 'set null',
onUpdate: 'cascade',
}),
// ...
});
type Project = InferSelectModel<typeof projects>;
export const projectsTranslations = pgTable('projects_t',
{
id: nanoid('id').references(() => projects.id, {
onDelete: 'cascade',
onUpdate: 'cascade',
}).notNull(),
locale: locale('locale').references(() => locales.locale, {
onDelete: 'cascade',
onUpdate: 'cascade',
}).notNull(),
title: text('title').notNull(),
summary: text('summary'),
description: text('description'),
},
(table) => {
return {
pk: primaryKey(table.id, table.locale),
unq: unique().on(table.locale, table.title),
};
}
);
type ProjectTranslation = InferSelectModel<typeof projectsTranslations>;
export const projects = pgTable('projects', {
id: nanoid('id').default(generateNanoid()).primaryKey(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
publishedAt: timestamp('published_at', { withTimezone: true }),
typeId: integer('type_id').references(() => projectTypes.id, {
onDelete: 'set null',
onUpdate: 'cascade',
}),
// ...
});
type Project = InferSelectModel<typeof projects>;
export const projectsTranslations = pgTable('projects_t',
{
id: nanoid('id').references(() => projects.id, {
onDelete: 'cascade',
onUpdate: 'cascade',
}).notNull(),
locale: locale('locale').references(() => locales.locale, {
onDelete: 'cascade',
onUpdate: 'cascade',
}).notNull(),
title: text('title').notNull(),
summary: text('summary'),
description: text('description'),
},
(table) => {
return {
pk: primaryKey(table.id, table.locale),
unq: unique().on(table.locale, table.title),
};
}
);
type ProjectTranslation = InferSelectModel<typeof projectsTranslations>;
I'm looking for a query that would return the data formatted as:
const selectedProjects: (Project & {translations: Record<Locale, Translation undefined>})[] = ...
const selectedProjects: (Project & {translations: Record<Locale, Translation undefined>})[] = ...
but I'm struggling to get anything else than:
const selectedProjecets: (Project & {translations: Translation[]})[] = ...
const selectedProjecets: (Project & {translations: Translation[]})[] = ...
10 Replies
iolyd
iolydOP15mo ago
Right now I'm using a reduce helper in to map the query data in js, but I'd rather use sql (json_object_agg()?) for the final aggregation formatting.
Pillow
Pillow15mo ago
I wrote an utility function for json aggregation
import type { SQL, InferColumnsDataTypes } from "drizzle-orm";
import type { PgColumn } from "drizzle-orm/pg-core";
import { sql, DrizzleError } from "drizzle-orm";

export function jsonAgg<
T extends Record<string, PgColumn>,
>(select: T) {
const chunks: SQL[] = [];
const entries = Object.entries(select);

if (!entries.length) {
throw new DrizzleError('Cannot aggregate an empty object');
}

entries.forEach(([key, column], index) => {
if (index > 0) chunks.push(sql`,`);
chunks.push(sql.raw(`'${key}',`), sql`${column}`);
});

const filterChunks = entries.filter(([_, col]) => !col.notNull).map(([_, col]) => col);
const filterSql = filterChunks.length ? sql`FILTER (WHERE ${sql.join(filterChunks)} IS NOT NULL)` : "";

return sql<InferColumnsDataTypes<T>[]>`
COALESCE(json_agg(json_build_object(${sql.join(chunks)})) ${filterSql}, '[]')
`;
}
import type { SQL, InferColumnsDataTypes } from "drizzle-orm";
import type { PgColumn } from "drizzle-orm/pg-core";
import { sql, DrizzleError } from "drizzle-orm";

export function jsonAgg<
T extends Record<string, PgColumn>,
>(select: T) {
const chunks: SQL[] = [];
const entries = Object.entries(select);

if (!entries.length) {
throw new DrizzleError('Cannot aggregate an empty object');
}

entries.forEach(([key, column], index) => {
if (index > 0) chunks.push(sql`,`);
chunks.push(sql.raw(`'${key}',`), sql`${column}`);
});

const filterChunks = entries.filter(([_, col]) => !col.notNull).map(([_, col]) => col);
const filterSql = filterChunks.length ? sql`FILTER (WHERE ${sql.join(filterChunks)} IS NOT NULL)` : "";

return sql<InferColumnsDataTypes<T>[]>`
COALESCE(json_agg(json_build_object(${sql.join(chunks)})) ${filterSql}, '[]')
`;
}
It may look horrible, but it works :D Here's an example
Pillow
Pillow15mo ago
No description
Pillow
Pillow15mo ago
oh, sorry, I misunderstood the question. :D
iolyd
iolydOP15mo ago
No worries, it's still useful for me to just look at this code!
Pillow
Pillow15mo ago
I can give u solution, but u need to use typescript instead of sql query ;d
const aggregatedUserData: (User & {groups: Record<Group['id'], Group[]>
const aggregatedUserData: (User & {groups: Record<Group['id'], Group[]>
No description
iolyd
iolydOP15mo ago
Yeah, I already have a ts solution but I'd rather have postgres run the aggregation :/ Here's a sample of sql query i'm essentially trying to translate to drizzle:
select t.*, json_object_agg(ttl.locale, ttl) as translations
from project_types as t
left join (
select tt.*
from project_types_t as tt
right join i18n.locales as l
on tt.locale = l.locale
) as ttl
on t.id = ttl.id
group by t.id
select t.*, json_object_agg(ttl.locale, ttl) as translations
from project_types as t
left join (
select tt.*
from project_types_t as tt
right join i18n.locales as l
on tt.locale = l.locale
) as ttl
on t.id = ttl.id
group by t.id
This appears to work:
export function translationsAgg<T extends Record<string, AnyColumn>>(shape: T) {
const chunks: SQL[] = [];

Object.entries(shape).forEach(([key, col]) => {
if (chunks.length > 0) {
chunks.push(sql.raw(`,`));
}
chunks.push(sql.raw(`'${key}',`));
chunks.push(sql`${col}`);
});

return sql<Record<Locale, InferColumnsDataTypes<T>>>`json_object_agg(${
locales.locale
}, json_build_object(${sql.join(chunks)}))`;
}
export function translationsAgg<T extends Record<string, AnyColumn>>(shape: T) {
const chunks: SQL[] = [];

Object.entries(shape).forEach(([key, col]) => {
if (chunks.length > 0) {
chunks.push(sql.raw(`,`));
}
chunks.push(sql.raw(`'${key}',`));
chunks.push(sql`${col}`);
});

return sql<Record<Locale, InferColumnsDataTypes<T>>>`json_object_agg(${
locales.locale
}, json_build_object(${sql.join(chunks)}))`;
}
I'll prolly make it into a more generic / reusable helper for my schemas. Ended up being easier than i thought (hope i'm not forgetting something major). For posterity, here's the helpers I made basing myself on your code and a few other examples found across discord threads and gh issues:
/**
* Build objects using `json_build_object(k1, v1, ...kn, vn).
*
* ⚠️ Vulnerable to SQL injections if used with user-input ⚠️
*/
export function jsonBuildObject<T extends Record<string, AnyColumn>>(shape: T) {
const chunks: SQL[] = [];
Object.entries(shape).forEach(([key, value]) => {
if (chunks.length > 0) {
chunks.push(sql.raw(`,`));
}
chunks.push(sql.raw(`'${key}',`));
chunks.push(sql`${value}`);
});
return sql<InferColumnsDataTypes<T>>`json_build_object(${sql.join(chunks)})`;
}

type InferColumnDataType<T extends AnyColumn> = T['_']['notNull'] extends true
? T['_']['data']
: T['_']['data'] | null;

/**
* Build object using `json_object_agg`.
*/
export function jsonObjectAgg<
K extends AnyColumn,
V extends AnyTable | Record<string, AnyColumn>,
TK extends string | number = null extends InferColumnDataType<K>
? never
: InferColumnDataType<K> extends string | number
? InferColumnDataType<K>
: never,
TV = V extends AnyTable
? InferSelectModel<V>
: V extends Record<string, AnyColumn>
? InferColumnsDataTypes<V>
: never,
>(key: K, value: V) {
const v = value instanceof Table ? value : jsonBuildObject(value).getSQL();
return sql<Record<TK, TV>>`json_object_agg(${key}, ${v})`;
}
/**
* Build objects using `json_build_object(k1, v1, ...kn, vn).
*
* ⚠️ Vulnerable to SQL injections if used with user-input ⚠️
*/
export function jsonBuildObject<T extends Record<string, AnyColumn>>(shape: T) {
const chunks: SQL[] = [];
Object.entries(shape).forEach(([key, value]) => {
if (chunks.length > 0) {
chunks.push(sql.raw(`,`));
}
chunks.push(sql.raw(`'${key}',`));
chunks.push(sql`${value}`);
});
return sql<InferColumnsDataTypes<T>>`json_build_object(${sql.join(chunks)})`;
}

type InferColumnDataType<T extends AnyColumn> = T['_']['notNull'] extends true
? T['_']['data']
: T['_']['data'] | null;

/**
* Build object using `json_object_agg`.
*/
export function jsonObjectAgg<
K extends AnyColumn,
V extends AnyTable | Record<string, AnyColumn>,
TK extends string | number = null extends InferColumnDataType<K>
? never
: InferColumnDataType<K> extends string | number
? InferColumnDataType<K>
: never,
TV = V extends AnyTable
? InferSelectModel<V>
: V extends Record<string, AnyColumn>
? InferColumnsDataTypes<V>
: never,
>(key: K, value: V) {
const v = value instanceof Table ? value : jsonBuildObject(value).getSQL();
return sql<Record<TK, TV>>`json_object_agg(${key}, ${v})`;
}
That solves my need if used as:
const projectTypes = await db
.select({
...getTableColumns(projectTypes),
translations: jsonObjectAgg(locales.locale, projectTypesTranslations),
})
.from(projectTypes)
.leftJoin(projectTypesTranslations, eq(projectTypes.id, projectTypesTranslations.id))
.rightJoin(locales, eq(locales.locale, projectTypesTranslations.locale))
.groupBy(projectTypes.id);
const projectTypes = await db
.select({
...getTableColumns(projectTypes),
translations: jsonObjectAgg(locales.locale, projectTypesTranslations),
})
.from(projectTypes)
.leftJoin(projectTypesTranslations, eq(projectTypes.id, projectTypesTranslations.id))
.rightJoin(locales, eq(locales.locale, projectTypesTranslations.locale))
.groupBy(projectTypes.id);
Personally, I think official helpers that implement this in a better way should be part of drizzle
Pillow
Pillow15mo ago
Are u sure that if the translations are empty u won't get a null object?
iolyd
iolydOP15mo ago
I figure I'd get something like this. Unless I'm mistaken, thats what I want.
{
translations: {
en: null,
fr: {
title: 'Un titre'
}
}
}
{
translations: {
en: null,
fr: {
title: 'Un titre'
}
}
}
Pillow
Pillow15mo ago
ah, sorry, yeap. anyway gj :D
Want results from more Discord servers?
Add your server