mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-09 23:25:30 -05:00
Fix json parse plugin crash
This commit is contained in:
parent
1dad69eba0
commit
10cb3e0c57
42
app/db/json-columns.test.ts
Normal file
42
app/db/json-columns.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JSON_COLUMNS } from "./json-columns";
|
||||
|
||||
describe("JSON_COLUMNS", () => {
|
||||
it("matches the JSONColumnType declarations in tables.ts", () => {
|
||||
expect([...JSON_COLUMNS].sort()).toEqual(jsonColumnsFromTablesSource());
|
||||
});
|
||||
});
|
||||
|
||||
function jsonColumnsFromTablesSource() {
|
||||
const source = readFileSync(new URL("./tables.ts", import.meta.url), "utf8");
|
||||
|
||||
const jsonColumnsByInterface = new Map<string, string[]>();
|
||||
const interfaceRegex = /(?:export )?interface (\w+) \{([\s\S]*?)\n\}/g;
|
||||
for (const match of source.matchAll(interfaceRegex)) {
|
||||
const [, interfaceName, body] = match;
|
||||
if (interfaceName === "DB") continue;
|
||||
|
||||
const columns = [];
|
||||
for (const line of body.split("\n")) {
|
||||
const columnMatch = line.match(
|
||||
/^\s*(\w+)\??:\s*.*JSONColumnType(?:Nullable)?</,
|
||||
);
|
||||
if (columnMatch) columns.push(columnMatch[1]);
|
||||
}
|
||||
if (columns.length > 0) {
|
||||
jsonColumnsByInterface.set(interfaceName, columns);
|
||||
}
|
||||
}
|
||||
|
||||
const dbInterfaceBody = source.slice(source.indexOf("export interface DB {"));
|
||||
const entries = [];
|
||||
for (const match of dbInterfaceBody.matchAll(/^\t(\w+): (\w+);/gm)) {
|
||||
const [, tableName, interfaceName] = match;
|
||||
for (const column of jsonColumnsByInterface.get(interfaceName) ?? []) {
|
||||
entries.push(`${tableName}.${column}`);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort();
|
||||
}
|
||||
51
app/db/json-columns.ts
Normal file
51
app/db/json-columns.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Every "Table.column" whose text content is a JSON document. The node-sqlite
|
||||
* dialect parses only these columns (plus computed expression columns) when
|
||||
* reading rows; all other text columns stay plain strings even if a user typed
|
||||
* something JSON-shaped into them. Kept in sync with the JSONColumnType
|
||||
* declarations of tables.ts by json-columns.test.ts.
|
||||
*/
|
||||
export const JSON_COLUMNS: ReadonlySet<string> = new Set([
|
||||
"AllTeam.customTheme",
|
||||
"AllTeam.mapModePreferences",
|
||||
"Build.abilities",
|
||||
"Build.modes",
|
||||
"CalendarEvent.tags",
|
||||
"GroupMatch.memento",
|
||||
"IngestedMatch.data",
|
||||
"LFGPost.languages",
|
||||
"Notification.meta",
|
||||
"NotificationUserSubscription.subscription",
|
||||
"ScrimPost.visibility",
|
||||
"SplatoonPlayer.peakXp",
|
||||
"Team.customTheme",
|
||||
"Team.mapModePreferences",
|
||||
"Tournament.castTwitchAccounts",
|
||||
"Tournament.castedMatchesInfo",
|
||||
"Tournament.preparedMaps",
|
||||
"Tournament.seedingSnapshot",
|
||||
"Tournament.settings",
|
||||
"TournamentAuditLog.metadata",
|
||||
"TournamentMatch.opponentOne",
|
||||
"TournamentMatch.opponentTwo",
|
||||
"TournamentOrganization.socials",
|
||||
"TournamentOrganizationSeries.substringMatches",
|
||||
"TournamentOrganizationSeries.tierHistory",
|
||||
"TournamentResult.setResults",
|
||||
"TournamentRound.maps",
|
||||
"TournamentStage.settings",
|
||||
"TournamentTeam.activeRosterUserIds",
|
||||
"User.buildSorting",
|
||||
"User.customTheme",
|
||||
"User.favoriteBadgeIds",
|
||||
"User.favoriteTrophyIds",
|
||||
"User.hiddenCardStats",
|
||||
"User.hiddenTrophyIds",
|
||||
"User.languages",
|
||||
"User.mapModePreferences",
|
||||
"User.preferences",
|
||||
"User.pronouns",
|
||||
"User.unverifiedPeakXP",
|
||||
"User.weaponPool",
|
||||
"UserWidget.widget",
|
||||
]);
|
||||
131
app/db/json-selections.test.ts
Normal file
131
app/db/json-selections.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { sql } from "kysely";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { commonUserSelect, jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { withUserId } from "~/utils/Test";
|
||||
import { computedJsonColumns } from "./json-selections";
|
||||
import { db } from "./sql";
|
||||
import type { Tables } from "./tables";
|
||||
|
||||
const JSON_SHAPED_TEXT = '{"note":"gg"}';
|
||||
|
||||
describe("computedJsonColumns", () => {
|
||||
it("recognizes a json helper selection but not a coalesce over user text", () => {
|
||||
const query = db
|
||||
.selectFrom("User")
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb, { inTournament: true }),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("UserWeapon")
|
||||
.select("UserWeapon.weaponSplId")
|
||||
.whereRef("UserWeapon.userId", "=", "User.id"),
|
||||
).as("weapons"),
|
||||
]);
|
||||
|
||||
// `username` is `coalesce("User"."tournamentName", "User"."username")`, which
|
||||
// SQLite reports the same way as the weapons subquery: as a computed column
|
||||
expect(computedJsonColumns(query.compile().query)).toEqual(
|
||||
new Set(["weapons"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes a json column contributed by another branch of a compound select", () => {
|
||||
const query = db
|
||||
.selectFrom("CalendarEventResultTeam")
|
||||
.select([
|
||||
sql<Tables["TournamentResult"]["setResults"]>`null`.as("setResults"),
|
||||
])
|
||||
.unionAll(
|
||||
db.selectFrom("TournamentResult").select("TournamentResult.setResults"),
|
||||
);
|
||||
|
||||
expect(computedJsonColumns(query.compile().query)).toEqual(
|
||||
new Set(["setResults"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes a json selection passed through a derived table", () => {
|
||||
const query = db
|
||||
.selectFrom((eb) =>
|
||||
eb
|
||||
.selectFrom("Build")
|
||||
.select((innerEb) => [
|
||||
"Build.id",
|
||||
jsonArrayFrom(
|
||||
innerEb
|
||||
.selectFrom("BuildWeapon")
|
||||
.select("BuildWeapon.weaponSplId")
|
||||
.whereRef("BuildWeapon.buildId", "=", "Build.id"),
|
||||
).as("weapons"),
|
||||
])
|
||||
.as("Inner"),
|
||||
)
|
||||
.select(["Inner.id", "Inner.weapons"]);
|
||||
|
||||
expect(computedJsonColumns(query.compile().query)).toEqual(
|
||||
new Set(["weapons"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading rows", () => {
|
||||
it("keeps a JSON-object-shaped in-tournament name as text", async () => {
|
||||
const [organizer, member] = await UserFactory.createMany(2);
|
||||
const tournament = await TournamentFactory.create({
|
||||
authorId: organizer.id,
|
||||
});
|
||||
|
||||
await withUserId(organizer.id, () =>
|
||||
TournamentTeamRepository.upsertRegistration({
|
||||
tournamentId: tournament.id,
|
||||
name: "Team Olive",
|
||||
teamId: null,
|
||||
avatarImgId: null,
|
||||
ownerUserId: member.id,
|
||||
ownerChange: null,
|
||||
membersToAdd: [member.id],
|
||||
membersToRemove: [],
|
||||
inGameNameUpdates: [],
|
||||
tournamentNameUpdates: [
|
||||
{ userId: member.id, tournamentName: JSON_SHAPED_TEXT },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const row = await db
|
||||
.selectFrom("User")
|
||||
.select((eb) => commonUserSelect(eb, { inTournament: true }))
|
||||
.where("User.id", "=", member.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// rendered as a bare JSX child on public tournament pages, so an object here
|
||||
// is "Objects are not valid as a React child" for everyone viewing them
|
||||
expect(row.username).toBe(JSON_SHAPED_TEXT);
|
||||
});
|
||||
|
||||
it("parses json columns and json helper selections", async () => {
|
||||
const user = await UserFactory.create(undefined, {
|
||||
matchProfile: { languages: ["en", "ja"] },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.selectFrom("User")
|
||||
.select((eb) => [
|
||||
"User.languages",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("User as Self")
|
||||
.select("Self.id")
|
||||
.whereRef("Self.id", "=", "User.id"),
|
||||
).as("self"),
|
||||
])
|
||||
.where("User.id", "=", user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.languages).toEqual(["en", "ja"]);
|
||||
expect(row.self).toEqual([{ id: user.id }]);
|
||||
});
|
||||
});
|
||||
153
app/db/json-selections.ts
Normal file
153
app/db/json-selections.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import {
|
||||
AggregateFunctionNode,
|
||||
AliasNode,
|
||||
ColumnNode,
|
||||
FunctionNode,
|
||||
IdentifierNode,
|
||||
type OperationNode,
|
||||
RawNode,
|
||||
ReferenceNode,
|
||||
type RootOperationNode,
|
||||
SelectQueryNode,
|
||||
} from "kysely";
|
||||
import { JSON_COLUMNS } from "./json-columns";
|
||||
|
||||
/** SQL shapes {@link jsonValuedNode} recognizes: the subqueries the json helpers emit and direct `json*(` function calls (e.g. `jsonBuildObject`, `json_set`). */
|
||||
const JSON_EXPRESSION_PREFIX =
|
||||
/^\s*(\(select\s+(coalesce\()?)?json(_\w+)?\s*\(/i;
|
||||
|
||||
const NO_NAMES: ReadonlySet<string> = new Set();
|
||||
|
||||
/** Output names of the query's computed result columns whose value is a JSON document, e.g. the subquery a `jsonArrayFrom` selection compiles to. */
|
||||
export function computedJsonColumns(
|
||||
query: RootOperationNode,
|
||||
): ReadonlySet<string> {
|
||||
// only select queries have computed result columns: `returning` selections keep
|
||||
// the origin metadata of the column they write to
|
||||
return SelectQueryNode.is(query) ? outputNames(query) : NO_NAMES;
|
||||
}
|
||||
|
||||
/** Output name a selection comes back under, or `undefined` for selections that have none (`selectAll()`). */
|
||||
export function selectionOutputName(
|
||||
selection: OperationNode,
|
||||
): string | undefined {
|
||||
if (ReferenceNode.is(selection) && ColumnNode.is(selection.column)) {
|
||||
return selection.column.column.name;
|
||||
}
|
||||
if (ColumnNode.is(selection)) {
|
||||
return selection.column.name;
|
||||
}
|
||||
if (AliasNode.is(selection) && IdentifierNode.is(selection.alias)) {
|
||||
return selection.alias.name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether a select list entry resolves to a JSON document. */
|
||||
export function jsonValuedSelection(
|
||||
selection: OperationNode,
|
||||
sources?: SourceOutputNames,
|
||||
): boolean {
|
||||
if (AliasNode.is(selection)) return jsonValuedNode(selection.node, sources);
|
||||
|
||||
return jsonValuedReference(selection, sources);
|
||||
}
|
||||
|
||||
/** Whether an expression resolves to a JSON document. */
|
||||
export function jsonValuedNode(
|
||||
node: OperationNode,
|
||||
sources?: SourceOutputNames,
|
||||
): boolean {
|
||||
if (RawNode.is(node)) {
|
||||
return JSON_EXPRESSION_PREFIX.test(node.sqlFragments[0] ?? "");
|
||||
}
|
||||
if (AggregateFunctionNode.is(node) || FunctionNode.is(node)) {
|
||||
return node.func.startsWith("json");
|
||||
}
|
||||
if (SelectQueryNode.is(node)) {
|
||||
const selections = node.selections ?? [];
|
||||
return (
|
||||
selections.length === 1 &&
|
||||
jsonValuedSelection(selections[0].selection, sourceOutputNames(node))
|
||||
);
|
||||
}
|
||||
|
||||
return jsonValuedReference(node, sources);
|
||||
}
|
||||
|
||||
/** JSON-valued output names of the derived tables and CTEs a query selects from, by the name they are visible under. */
|
||||
type SourceOutputNames = ReadonlyMap<string, ReadonlySet<string>>;
|
||||
|
||||
function outputNames(select: SelectQueryNode): ReadonlySet<string> {
|
||||
const sources = sourceOutputNames(select);
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const { selection } of select.selections ?? []) {
|
||||
const name = selectionOutputName(selection);
|
||||
if (name && jsonValuedSelection(selection, sources)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// a compound select is named after its first branch, but any branch can be the
|
||||
// one contributing the JSON document
|
||||
for (const { expression } of select.setOperations ?? []) {
|
||||
if (!SelectQueryNode.is(expression)) continue;
|
||||
|
||||
for (const name of outputNames(expression)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function sourceOutputNames(select: SelectQueryNode): SourceOutputNames {
|
||||
const sources = new Map<string, ReadonlySet<string>>();
|
||||
|
||||
for (const cte of select.with?.expressions ?? []) {
|
||||
if (!SelectQueryNode.is(cte.expression)) continue;
|
||||
|
||||
sources.set(
|
||||
cte.name.table.table.identifier.name,
|
||||
outputNames(cte.expression),
|
||||
);
|
||||
}
|
||||
|
||||
const tables = [
|
||||
...(select.from?.froms ?? []),
|
||||
...(select.joins ?? []).map((join) => join.table),
|
||||
];
|
||||
for (const table of tables) {
|
||||
if (
|
||||
!AliasNode.is(table) ||
|
||||
!SelectQueryNode.is(table.node) ||
|
||||
!IdentifierNode.is(table.alias)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sources.set(table.alias.name, outputNames(table.node));
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function jsonValuedReference(node: OperationNode, sources?: SourceOutputNames) {
|
||||
if (
|
||||
!ReferenceNode.is(node) ||
|
||||
!ColumnNode.is(node.column) ||
|
||||
node.table === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const table = node.table.table.identifier.name;
|
||||
const column = node.column.column.name;
|
||||
|
||||
return (
|
||||
JSON_COLUMNS.has(`${table}.${column}`) ||
|
||||
Boolean(sources?.get(table)?.has(column))
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type QueryCompiler,
|
||||
type QueryResult,
|
||||
RawNode,
|
||||
type RootOperationNode,
|
||||
SelectQueryNode,
|
||||
SqliteAdapter,
|
||||
SqliteIntrospector,
|
||||
|
|
@ -57,6 +58,8 @@ const SCHEMA_PRESERVING_RAW_COMMANDS = new Set([
|
|||
|
||||
const STATEMENT_CACHE_SIZE = 5000;
|
||||
|
||||
const NO_JSON_OUTPUT_NAMES: ReadonlySet<string> = new Set();
|
||||
|
||||
export interface NodeSqliteDialectConfig {
|
||||
database: DatabaseSync;
|
||||
/**
|
||||
|
|
@ -66,6 +69,23 @@ export interface NodeSqliteDialectConfig {
|
|||
* connection, which is not true while migrations run.
|
||||
*/
|
||||
cacheStatements?: boolean;
|
||||
/**
|
||||
* "Table.column" names whose text content is a JSON document. When given,
|
||||
* result values of these columns are parsed into objects. Other text columns
|
||||
* are always returned verbatim, so JSON-shaped user input stays a string.
|
||||
* Origin metadata from `statement.columns()` sees through aliases, views,
|
||||
* subqueries and CTEs, so the names here are the underlying table names
|
||||
* (e.g. `AllTeam`, not the `Team` view).
|
||||
*/
|
||||
jsonColumns?: ReadonlySet<string>;
|
||||
/**
|
||||
* Output names of a query's computed result columns whose value is a JSON
|
||||
* document, which is what `jsonArrayFrom`/`jsonObjectFrom` subqueries compile
|
||||
* to. SQLite reports no origin for a computed expression, so only the query's
|
||||
* own AST tells those apart from an ordinary `coalesce(...)` over user text.
|
||||
* Called once per prepared statement. Requires {@link jsonColumns}.
|
||||
*/
|
||||
computedJsonColumns?: (query: RootOperationNode) => ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,20 +194,36 @@ class NodeSqliteDriver implements Driver {
|
|||
}
|
||||
}
|
||||
|
||||
/** Which result columns of one query hold a JSON document: by column origin, and by output name for the columns that have no origin. */
|
||||
interface JsonColumns {
|
||||
byOrigin: ReadonlySet<string>;
|
||||
byOutputName: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface PreparedStatement {
|
||||
statement: StatementSync;
|
||||
/** Empty for statements that return no rows, which is how writes are detected. */
|
||||
columnNames: string[];
|
||||
/** Per result column: parse text values as JSON when building rows. */
|
||||
jsonColumnFlags: boolean[];
|
||||
/** Kept for re-deriving the flags when the column list turns out to be stale. */
|
||||
jsonColumns?: JsonColumns;
|
||||
}
|
||||
|
||||
class NodeSqliteConnection implements DatabaseConnection {
|
||||
readonly #database: DatabaseSync;
|
||||
readonly #cacheStatements: boolean;
|
||||
readonly #jsonColumns?: ReadonlySet<string>;
|
||||
readonly #computedJsonColumns?: (
|
||||
query: RootOperationNode,
|
||||
) => ReadonlySet<string>;
|
||||
readonly #cache = new Map<string, PreparedStatement>();
|
||||
|
||||
constructor(config: NodeSqliteDialectConfig) {
|
||||
this.#database = config.database;
|
||||
this.#cacheStatements = config.cacheStatements ?? false;
|
||||
this.#jsonColumns = config.jsonColumns;
|
||||
this.#computedJsonColumns = config.computedJsonColumns;
|
||||
}
|
||||
|
||||
async executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
|
||||
|
|
@ -218,14 +254,16 @@ class NodeSqliteConnection implements DatabaseConnection {
|
|||
|
||||
// deliberately uncached: the cursor stays open across yields, so sharing the
|
||||
// statement with another query would reset it mid-iteration
|
||||
const prepared = prepare(this.#database, compiledQuery.sql);
|
||||
const prepared = prepare(
|
||||
this.#database,
|
||||
compiledQuery.sql,
|
||||
this.#jsonColumnsFor(compiledQuery.query),
|
||||
);
|
||||
const parameters = compiledQuery.parameters as SQLInputValue[];
|
||||
|
||||
for (const row of prepared.statement.iterate(...parameters)) {
|
||||
yield {
|
||||
rows: [
|
||||
toRow<R>(prepared.columnNames, row as unknown as SQLOutputValue[]),
|
||||
],
|
||||
rows: [toRow<R>(prepared, row as unknown as SQLOutputValue[])],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -243,7 +281,7 @@ class NodeSqliteConnection implements DatabaseConnection {
|
|||
this.#cache.clear();
|
||||
}
|
||||
|
||||
return prepare(this.#database, sql);
|
||||
return prepare(this.#database, sql, this.#jsonColumnsFor(query));
|
||||
}
|
||||
|
||||
const cached = this.#cache.get(sql);
|
||||
|
|
@ -254,7 +292,7 @@ class NodeSqliteConnection implements DatabaseConnection {
|
|||
return cached;
|
||||
}
|
||||
|
||||
const prepared = prepare(this.#database, sql);
|
||||
const prepared = prepare(this.#database, sql, this.#jsonColumnsFor(query));
|
||||
|
||||
if (this.#cache.size >= STATEMENT_CACHE_SIZE) {
|
||||
this.#cache.delete(this.#cache.keys().next().value!);
|
||||
|
|
@ -263,6 +301,15 @@ class NodeSqliteConnection implements DatabaseConnection {
|
|||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
#jsonColumnsFor(query: RootOperationNode): JsonColumns | undefined {
|
||||
if (!this.#jsonColumns) return undefined;
|
||||
|
||||
return {
|
||||
byOrigin: this.#jsonColumns,
|
||||
byOutputName: this.#computedJsonColumns?.(query) ?? NO_JSON_OUTPUT_NAMES,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function canChangeSchema(sql: string) {
|
||||
|
|
@ -274,11 +321,33 @@ function canChangeSchema(sql: string) {
|
|||
return !SCHEMA_PRESERVING_RAW_COMMANDS.has(firstKeyword);
|
||||
}
|
||||
|
||||
function prepare(database: DatabaseSync, sql: string): PreparedStatement {
|
||||
function prepare(
|
||||
database: DatabaseSync,
|
||||
sql: string,
|
||||
jsonColumns: JsonColumns | undefined,
|
||||
): PreparedStatement {
|
||||
const statement = database.prepare(sql);
|
||||
statement.setReturnArrays(true);
|
||||
|
||||
return { statement, columnNames: statement.columns().map((it) => it.name) };
|
||||
return { statement, jsonColumns, ...columnMetadata(statement, jsonColumns) };
|
||||
}
|
||||
|
||||
function columnMetadata(
|
||||
statement: StatementSync,
|
||||
jsonColumns: JsonColumns | undefined,
|
||||
) {
|
||||
const columns = statement.columns();
|
||||
|
||||
return {
|
||||
columnNames: columns.map((it) => it.name),
|
||||
jsonColumnFlags: columns.map((it) => {
|
||||
if (!jsonColumns) return false;
|
||||
// a null origin is a computed expression (a jsonArrayFrom subquery, but also
|
||||
// e.g. a coalesce over user text), which only the query itself can classify
|
||||
if (it.column === null) return jsonColumns.byOutputName.has(it.name);
|
||||
return jsonColumns.byOrigin.has(`${it.table}.${it.column}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readRows<R>(
|
||||
|
|
@ -294,26 +363,91 @@ function readRows<R>(
|
|||
// `select *` widens when a migration adds a column, leaving a cached statement
|
||||
// with a stale column list until the next read notices the mismatch
|
||||
if (rawRows[0].length !== prepared.columnNames.length) {
|
||||
prepared.columnNames = prepared.statement.columns().map((it) => it.name);
|
||||
Object.assign(
|
||||
prepared,
|
||||
columnMetadata(prepared.statement, prepared.jsonColumns),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = new Array<R>(rawRows.length);
|
||||
for (let i = 0; i < rawRows.length; i++) {
|
||||
rows[i] = toRow<R>(prepared.columnNames, rawRows[i]);
|
||||
rows[i] = toRow<R>(prepared, rawRows[i]);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toRow<R>(columnNames: string[], rawRow: SQLOutputValue[]): R {
|
||||
const row: Record<string, SQLOutputValue> = {};
|
||||
function toRow<R>(prepared: PreparedStatement, rawRow: SQLOutputValue[]): R {
|
||||
const { columnNames, jsonColumnFlags } = prepared;
|
||||
|
||||
const row: Record<string, unknown> = {};
|
||||
for (let i = 0; i < columnNames.length; i++) {
|
||||
row[columnNames[i]] = rawRow[i];
|
||||
const value = rawRow[i];
|
||||
row[columnNames[i]] =
|
||||
jsonColumnFlags[i] && typeof value === "string" && maybeJson(value)
|
||||
? parseJsonValue(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
return row as R;
|
||||
}
|
||||
|
||||
function maybeJson(value: string) {
|
||||
return (
|
||||
(value.startsWith("{") && value.endsWith("}")) ||
|
||||
(value.startsWith("[") && value.endsWith("]"))
|
||||
);
|
||||
}
|
||||
|
||||
function parseJsonValue(value: string): unknown {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
|
||||
sanitizeParsedJson(parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Strips `__proto__` and `constructor.prototype` so the parsed document can not prototype-pollute downstream merges. */
|
||||
function sanitizeParsedJson(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
sanitizeParsedJson(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) return;
|
||||
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key === "__proto__") {
|
||||
delete value[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = value[key];
|
||||
if (
|
||||
key === "constructor" &&
|
||||
isPlainObject(child) &&
|
||||
Object.hasOwn(child, "prototype")
|
||||
) {
|
||||
delete child.prototype;
|
||||
}
|
||||
|
||||
sanitizeParsedJson(child);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === null || proto === Object.prototype;
|
||||
}
|
||||
|
||||
function savepointCommand(command: string, savepointName: string) {
|
||||
return RawNode.createWithChildren([
|
||||
RawNode.createWithSql(`${command} `),
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import type {
|
||||
KyselyPlugin,
|
||||
PluginTransformQueryArgs,
|
||||
PluginTransformResultArgs,
|
||||
QueryResult,
|
||||
RootOperationNode,
|
||||
UnknownRow,
|
||||
} from "kysely";
|
||||
|
||||
/**
|
||||
* Drop-in replacement for Kysely's `ParseJSONResultsPlugin`. Produces the same
|
||||
* results but skips the per-node reviver callback, jsonPath string building and
|
||||
* double tree walk of the original, making large JSON result sets several times
|
||||
* faster to transform.
|
||||
*/
|
||||
export class FastParseJSONResultsPlugin implements KyselyPlugin {
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
return args.node;
|
||||
}
|
||||
|
||||
async transformResult(
|
||||
args: PluginTransformResultArgs,
|
||||
): Promise<QueryResult<UnknownRow>> {
|
||||
for (const row of args.result.rows) {
|
||||
parseObjectInPlace(row, false);
|
||||
}
|
||||
|
||||
return args.result;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeJson(value: string) {
|
||||
return (
|
||||
(value.startsWith("{") && value.endsWith("}")) ||
|
||||
(value.startsWith("[") && value.endsWith("]"))
|
||||
);
|
||||
}
|
||||
|
||||
function parseValue(value: unknown): unknown {
|
||||
if (typeof value === "string" && maybeJson(value)) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return parseValue(parsed);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
value[i] = parseValue(value[i]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
parseObjectInPlace(value, true);
|
||||
return value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseObjectInPlace(
|
||||
obj: Record<string, unknown>,
|
||||
isParsedJson: boolean,
|
||||
) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
// prevent prototype pollution
|
||||
if (key === "__proto__") {
|
||||
if (isParsedJson) delete obj[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseValue(obj[key]);
|
||||
|
||||
// prevent prototype pollution
|
||||
if (
|
||||
key === "constructor" &&
|
||||
isPlainObject(parsed) &&
|
||||
Object.hasOwn(parsed, "prototype")
|
||||
) {
|
||||
delete parsed.prototype;
|
||||
}
|
||||
|
||||
obj[key] = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === null || proto === Object.prototype;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { add } from "date-fns";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { Pronouns, UserPreferences } from "~/db/tables-json";
|
||||
import type { CustomTheme, Pronouns, UserPreferences } from "~/db/tables-json";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
|
||||
|
|
@ -55,6 +55,8 @@ type Options = {
|
|||
widgets?: Parameters<typeof UserRepository.upsertWidgets>[1];
|
||||
/** Preferences, merged into the ones the user has, as the settings pages save them. */
|
||||
preferences?: UserPreferences;
|
||||
/** Custom theme, saved as the settings page saves it. Only shown to a supporter. */
|
||||
customTheme?: CustomTheme;
|
||||
};
|
||||
|
||||
type CardArgs = Parameters<typeof UserCardRepository.updateOwnCard>[0];
|
||||
|
|
@ -369,6 +371,7 @@ export async function grant(
|
|||
card,
|
||||
widgets,
|
||||
preferences,
|
||||
customTheme,
|
||||
}: Options,
|
||||
) {
|
||||
if (card) {
|
||||
|
|
@ -424,6 +427,10 @@ export async function grant(
|
|||
if (preferences) {
|
||||
await actAs(userId, () => UserRepository.updateOwnPreferences(preferences));
|
||||
}
|
||||
|
||||
if (customTheme) {
|
||||
await actAs(userId, () => UserRepository.updateOwnCustomTheme(customTheme));
|
||||
}
|
||||
}
|
||||
|
||||
async function setPlusTier(userId: number, plusTier: number) {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import { ServerConfig } from "~/config.server";
|
|||
import { logger } from "~/utils/logger";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import { EmptyValuesNoopPlugin } from "./empty-values-noop-plugin";
|
||||
import { JSON_COLUMNS } from "./json-columns";
|
||||
import { computedJsonColumns } from "./json-selections";
|
||||
import { NodeSqliteDialect } from "./node-sqlite-dialect";
|
||||
import { FastParseJSONResultsPlugin } from "./parse-json-results-plugin";
|
||||
import type { DB } from "./tables";
|
||||
import { WriteTrackerPlugin } from "./write-tracker";
|
||||
|
||||
|
|
@ -51,13 +52,11 @@ export const db = new Kysely<DB>({
|
|||
dialect: new NodeSqliteDialect({
|
||||
database: sql,
|
||||
cacheStatements: true,
|
||||
jsonColumns: JSON_COLUMNS,
|
||||
computedJsonColumns,
|
||||
}),
|
||||
log,
|
||||
plugins: [
|
||||
new EmptyValuesNoopPlugin(),
|
||||
new FastParseJSONResultsPlugin(),
|
||||
new WriteTrackerPlugin(),
|
||||
],
|
||||
plugins: [new EmptyValuesNoopPlugin(), new WriteTrackerPlugin()],
|
||||
});
|
||||
|
||||
// Every test worker gets its own in-memory database, built by replaying the
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/db/sql";
|
||||
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
|
||||
import {
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
} from "~/utils/kysely.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import type { GetTournamentOrganizationResponse } from "../schema";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/db/sql";
|
||||
|
|
@ -8,6 +7,7 @@ import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tourn
|
|||
import { resolveMapList } from "~/features/tournament-match/core/mapList.server";
|
||||
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
|
||||
import { parseMaplistSource } from "~/modules/tournament-map-list-generator/source";
|
||||
import { jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/db/sql";
|
||||
import { jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import type { GetTournamentStreamsResponse } from "../schema";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/db/sql";
|
||||
|
|
@ -10,6 +9,8 @@ import { nullifyingAvg } from "~/utils/arrays";
|
|||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import {
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
tournamentUsername,
|
||||
} from "~/utils/kysely.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/db/sql";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import type { GetTournamentResponse } from "../schema";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/db/sql";
|
||||
|
|
@ -6,7 +5,7 @@ import * as Seasons from "~/features/mmr/core/Seasons";
|
|||
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
|
||||
import { peakXpOverallSql } from "~/utils/kysely.server";
|
||||
import { jsonArrayFrom, peakXpOverallSql } from "~/utils/kysely.server";
|
||||
import { safeNumberParse } from "~/utils/number";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { badgeUrl } from "~/utils/urls";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { sql, type Transaction } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
} from "~/utils/kysely.server";
|
||||
import { seededRandom } from "~/utils/random";
|
||||
import type { ListedArt } from "./art-types";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
|
||||
|
|
@ -6,7 +5,7 @@ import { ASSOCIATION } from "~/features/associations/associations-constants";
|
|||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import { LimitReachedError } from "~/utils/errors";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
import { commonUserSelect, jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
interface FindOptions {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import type { ExpressionBuilder, NotNull } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, TablesInsertable } from "~/db/tables";
|
||||
import { sortBadgesByFavorites } from "~/features/user-page/core/badge-sorting.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { commonUserSelect, peakXpOverallSql } from "~/utils/kysely.server";
|
||||
import {
|
||||
commonUserSelect,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
peakXpOverallSql,
|
||||
} from "~/utils/kysely.server";
|
||||
import { SPLATOON_3_XP_BADGE_VALUES } from "./badges-constants";
|
||||
import { findSplatoon3XpBadgeValue } from "./badges-utils";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { type NotNull, sql, type Transaction } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { BuildWeapon, DB, TablesInsertable } from "~/db/tables";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
|
|
@ -13,7 +12,7 @@ import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
|
|||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { LimitReachedError } from "~/utils/errors";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { commonUserJsonObject } from "~/utils/kysely.server";
|
||||
import { commonUserJsonObject, jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { MAIN_SLOT_AP } from "../build-analyzer/analyzer-constants";
|
||||
import {
|
||||
buildToAbilityPoints,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type {
|
|||
Transaction,
|
||||
} from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
|
|
@ -24,6 +23,8 @@ import invariant from "~/utils/invariant";
|
|||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
tournamentLogoWithDefault,
|
||||
} from "~/utils/kysely.server";
|
||||
import { calendarEventPage, tournamentPage } from "~/utils/urls";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { InferResult } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
|
@ -11,6 +10,7 @@ import type {
|
|||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
latestSkillPerSeason,
|
||||
skillCountsAsSeasonSet,
|
||||
} from "~/utils/kysely.server";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { sub } from "date-fns";
|
||||
import { type NotNull, sql, type Transaction } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, TablesInsertable } from "~/db/tables";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
userProfileWeapons,
|
||||
} from "~/utils/kysely.server";
|
||||
import { LFG } from "./lfg-constants";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { formatDistance } from "date-fns";
|
||||
import type { ExpressionBuilder, Insertable, NotNull } from "kysely";
|
||||
import { jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import type { MonthYear } from "~/features/plus-voting/core";
|
||||
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
import { commonUserSelect, jsonObjectFrom } from "~/utils/kysely.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
import {
|
||||
isPlusTier,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { Transaction } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, TablesInsertable } from "~/db/tables";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import * as ScrimMapRepository from "./ScrimMapRepository.server";
|
||||
import type { ScrimSide } from "./scrims-types";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { sub } from "date-fns";
|
||||
import type { Insertable } from "kysely";
|
||||
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import { actorId, actorIdOrNull } from "~/features/auth/core/user.server";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
|
|
@ -13,6 +12,8 @@ import {
|
|||
type CommonUser,
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
jsonBuildObject,
|
||||
tournamentLogoWithDefault,
|
||||
} from "~/utils/kysely.server";
|
||||
import { db } from "../../db/sql";
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import {
|
|||
sql,
|
||||
type Transaction,
|
||||
} from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
commonUserJsonObject,
|
||||
jsonArrayFrom,
|
||||
latestSkillPerSeason,
|
||||
tournamentLogoWithDefault,
|
||||
} from "~/utils/kysely.server";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { startOfYear } from "date-fns";
|
||||
import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
|
|
@ -20,6 +19,8 @@ import invariant from "~/utils/invariant";
|
|||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
matchProfileWeapons,
|
||||
skillCountsAsSeasonSet,
|
||||
tournamentLogoWithDefault,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
import { commonUserSelect, jsonObjectFrom } from "~/utils/kysely.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
|
||||
export type ActiveMatchPlayersItem = Unwrapped<
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
sql,
|
||||
type Transaction,
|
||||
} from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { UserMapModePreferences } from "~/db/tables-json";
|
||||
|
|
@ -15,6 +14,7 @@ import { shortNanoid } from "~/utils/id";
|
|||
import {
|
||||
commonUserMembersAgg,
|
||||
commonUserSelect,
|
||||
jsonArrayFrom,
|
||||
matchProfileWeapons,
|
||||
} from "~/utils/kysely.server";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { type Insertable, sql, type Transaction } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { CustomTheme } from "~/db/tables-json";
|
||||
|
|
@ -13,6 +12,7 @@ import invariant from "~/utils/invariant";
|
|||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
tournamentLogoOrNull,
|
||||
userProfileWeapons,
|
||||
} from "~/utils/kysely.server";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { sql as kyselySql, type RawBuilder, type Transaction } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import { jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { matchStatuses } from "./core/engine/status";
|
||||
import type {
|
||||
BracketData,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { subDays, subHours } from "date-fns";
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
import {
|
||||
commonUserSelect,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
} from "~/utils/kysely.server";
|
||||
import { TOURNAMENT } from "../tournament/tournament-constants";
|
||||
|
||||
export type VodsByTournamentId = Awaited<
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { type Insertable, sql, type Transaction } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import type { TournamentRoundMaps } from "~/db/tables-json";
|
||||
import type { Side } from "~/features/tournament-bracket/core/engine/types";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
import { commonUserSelect, jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { isFuture } from "date-fns";
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
tournamentLogoWithDefault,
|
||||
} from "~/utils/kysely.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { sub } from "date-fns";
|
||||
import type { Transaction } from "kysely";
|
||||
import { jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { TournamentAuditLogMetadata } from "~/db/tables-json";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
import { commonUserSelect, jsonObjectFrom } from "~/utils/kysely.server";
|
||||
|
||||
export const AUDIT_LOG_PAGE_SIZE = 30;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { sub } from "date-fns";
|
||||
import { type Insertable, type NotNull, sql, type Transaction } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { ordinal } from "openskill";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
|
|
@ -25,6 +24,8 @@ import invariant from "~/utils/invariant";
|
|||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
tournamentLogoWithDefault,
|
||||
tournamentUsername,
|
||||
} from "~/utils/kysely.server";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { sub } from "date-fns";
|
||||
import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
|
|
@ -12,6 +11,8 @@ import {
|
|||
import {
|
||||
calendarEventStartTime,
|
||||
commonUserSelect,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
peakXpOverallSql,
|
||||
tournamentLogoWithDefault,
|
||||
tournamentTeamCount,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { sub } from "date-fns";
|
||||
import type { Expression, ExpressionBuilder } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
|
@ -18,8 +17,11 @@ import { LRUCache } from "~/modules/cache";
|
|||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
asJson,
|
||||
commonUserObjectFields,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonBuildObject,
|
||||
jsonObjectFrom,
|
||||
} from "~/utils/kysely.server";
|
||||
import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
|
||||
import type {
|
||||
|
|
@ -355,7 +357,9 @@ function userCardDataJsonObject(
|
|||
...commonUserObjectFields(eb),
|
||||
shortBio: eb.ref("User.shortBio"),
|
||||
div: eb.ref("User.div"),
|
||||
customTheme: sql<CustomTheme | null>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme", null)`,
|
||||
customTheme: asJson(
|
||||
sql<CustomTheme | null>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme", null)`,
|
||||
),
|
||||
hiddenCardStats: eb.ref("User.hiddenCardStats"),
|
||||
banner: bannerJson(eb),
|
||||
friendCode: include?.friendCode
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { ExpressionBuilder, NotNull } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables, TablesInsertable } from "~/db/tables";
|
||||
|
|
@ -20,9 +19,11 @@ import { isSupporter } from "~/modules/permissions/utils";
|
|||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
asJson,
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
customAvatarUrl,
|
||||
jsonArrayFrom,
|
||||
tournamentLogoOrNull,
|
||||
userByIdentifierQuery,
|
||||
userChatNameHue,
|
||||
|
|
@ -107,12 +108,9 @@ export function findLayoutDataByIdentifier(
|
|||
"PlusTier.tier as plusTier",
|
||||
"User.commissionText",
|
||||
"User.commissionsOpen",
|
||||
sql<Record<
|
||||
string,
|
||||
string
|
||||
> | null>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme", null)`.as(
|
||||
"customTheme",
|
||||
),
|
||||
asJson(
|
||||
sql<CustomTheme | null>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme", null)`,
|
||||
).as("customTheme"),
|
||||
eb
|
||||
.selectFrom("TournamentResult")
|
||||
.whereRef("TournamentResult.userId", "=", "User.id")
|
||||
|
|
|
|||
22
app/features/user-page/bio-json.server.test.ts
Normal file
22
app/features/user-page/bio-json.server.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as UserRepository from "./UserRepository.server";
|
||||
|
||||
describe("profile bio is always a string", () => {
|
||||
it("keeps a JSON-object-shaped bio as text (not a parsed object)", async () => {
|
||||
// a bio the user typed that happens to be valid JSON of object shape
|
||||
const user = await UserFactory.create({
|
||||
profile: { bio: '{"note":"gg"}' },
|
||||
});
|
||||
|
||||
const profile = await UserRepository.findProfileByIdentifier(
|
||||
String(user.id),
|
||||
);
|
||||
|
||||
// `User.bio` is a text column; the loader types it as `string | null` and the
|
||||
// profile page renders it directly as a React child. If it comes back as an
|
||||
// object, `<article>{data.user.bio}</article>` throws "Objects are not valid
|
||||
// as a React child" and the whole profile page 500s.
|
||||
expect(typeof profile?.bio).toBe("string");
|
||||
});
|
||||
});
|
||||
50
app/features/user-page/custom-theme-json.server.test.ts
Normal file
50
app/features/user-page/custom-theme-json.server.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { clampThemeToGamut } from "~/utils/oklch-gamut";
|
||||
import * as UserRepository from "./UserRepository.server";
|
||||
|
||||
const CUSTOM_THEME = clampThemeToGamut({
|
||||
baseHue: 268,
|
||||
baseChroma: 0.05,
|
||||
accentHue: 253,
|
||||
accentChroma: 0.24,
|
||||
chatHue: null,
|
||||
radiusBox: 3,
|
||||
radiusField: 2,
|
||||
radiusSelector: 2,
|
||||
borderWidth: 2,
|
||||
sizeField: 1,
|
||||
sizeSelector: 1,
|
||||
sizeSpacing: 1,
|
||||
});
|
||||
|
||||
describe("supporter custom theme on the profile layout", () => {
|
||||
it("comes back parsed", async () => {
|
||||
const user = await UserFactory.create(null, {
|
||||
patronTier: 2,
|
||||
customTheme: CUSTOM_THEME,
|
||||
});
|
||||
|
||||
const layoutData = await UserRepository.findLayoutDataByIdentifier(
|
||||
String(user.id),
|
||||
);
|
||||
|
||||
// `root.tsx` spreads `Object.entries(customTheme)` into the page's CSS
|
||||
// variables, so a raw string here renders as garbage instead of the theme
|
||||
expect(layoutData?.customTheme?.["--_acc-h"]).toBe(
|
||||
CUSTOM_THEME["--_acc-h"],
|
||||
);
|
||||
});
|
||||
|
||||
it("is null for a user who is not a supporter", async () => {
|
||||
const user = await UserFactory.create(null, {
|
||||
customTheme: CUSTOM_THEME,
|
||||
});
|
||||
|
||||
const layoutData = await UserRepository.findLayoutDataByIdentifier(
|
||||
String(user.id),
|
||||
);
|
||||
|
||||
expect(layoutData?.customTheme).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
|
@ -18,6 +17,7 @@ import {
|
|||
type CommonUser,
|
||||
commonUserJsonObject,
|
||||
commonUserSelect,
|
||||
jsonArrayFrom,
|
||||
} from "~/utils/kysely.server";
|
||||
import { VODS_PAGE_BATCH_SIZE } from "./vods-constants";
|
||||
import type { VideoBeingAdded, Vod } from "./vods-types";
|
||||
|
|
|
|||
|
|
@ -3,10 +3,20 @@ import {
|
|||
type ColumnType,
|
||||
type Expression,
|
||||
type ExpressionBuilder,
|
||||
type RawBuilder,
|
||||
sql,
|
||||
} from "kysely";
|
||||
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
|
||||
import type {
|
||||
jsonArrayFrom as sqliteJsonArrayFrom,
|
||||
jsonBuildObject as sqliteJsonBuildObject,
|
||||
jsonObjectFrom as sqliteJsonObjectFrom,
|
||||
} from "kysely/helpers/sqlite";
|
||||
import { Config } from "~/config";
|
||||
import {
|
||||
jsonValuedNode,
|
||||
jsonValuedSelection,
|
||||
selectionOutputName,
|
||||
} from "~/db/json-selections";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import { IS_E2E_TEST_RUN } from "./e2e";
|
||||
|
|
@ -406,3 +416,83 @@ export function tournamentUsername(alias = "User") {
|
|||
`${alias}.username`,
|
||||
)})`;
|
||||
}
|
||||
|
||||
type SelectQueryBuilderExpression<O> = Parameters<
|
||||
typeof sqliteJsonArrayFrom<O>
|
||||
>[0];
|
||||
|
||||
/**
|
||||
* Drop-in replacement for kysely's sqlite `jsonArrayFrom`. Emits the same query, except
|
||||
* JSON-valued selections (per {@link jsonValuedSelection}: JSON columns, nested json helpers) get
|
||||
* `json(...)` applied at the `json_object` argument position. SQLite's JSON subtype never
|
||||
* survives a subquery boundary, so without the re-tag such values would be embedded as
|
||||
* strings; the dialect parses each result column exactly once and relies on documents
|
||||
* arriving fully nested. Always use this over the kysely one.
|
||||
*/
|
||||
export function jsonArrayFrom<O>(
|
||||
expr: SelectQueryBuilderExpression<O>,
|
||||
): ReturnType<typeof sqliteJsonArrayFrom<O>> {
|
||||
return sql`(select coalesce(json_group_array(json_object(${sql.join(
|
||||
jsonObjectArgs(expr, "agg"),
|
||||
)})), '[]') from ${expr} as agg)` as ReturnType<
|
||||
typeof sqliteJsonArrayFrom<O>
|
||||
>;
|
||||
}
|
||||
|
||||
/** Drop-in replacement for kysely's sqlite `jsonObjectFrom`, see {@link jsonArrayFrom}. */
|
||||
export function jsonObjectFrom<O>(
|
||||
expr: SelectQueryBuilderExpression<O>,
|
||||
): ReturnType<typeof sqliteJsonObjectFrom<O>> {
|
||||
return sql`(select json_object(${sql.join(
|
||||
jsonObjectArgs(expr, "obj"),
|
||||
)}) from ${expr} as obj)` as ReturnType<typeof sqliteJsonObjectFrom<O>>;
|
||||
}
|
||||
|
||||
/** Drop-in replacement for kysely's sqlite `jsonBuildObject`, see {@link jsonArrayFrom}. */
|
||||
export function jsonBuildObject<O extends Record<string, Expression<unknown>>>(
|
||||
obj: O,
|
||||
): ReturnType<typeof sqliteJsonBuildObject<O>> {
|
||||
return sql`json_object(${sql.join(
|
||||
Object.keys(obj).flatMap((key) => [
|
||||
sql.lit(key),
|
||||
jsonValuedNode(obj[key].toOperationNode())
|
||||
? sql`json(${obj[key]})`
|
||||
: obj[key],
|
||||
]),
|
||||
)})` as ReturnType<typeof sqliteJsonBuildObject<O>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-tags a JSON-valued expression with SQLite's `json()` so it stays a nested document
|
||||
* (instead of an escaped string) inside {@link jsonBuildObject}/{@link jsonArrayFrom}.
|
||||
* Only needed for expressions the helpers can not recognize as JSON on their own, e.g. a
|
||||
* raw `IIF(...)` over a JSON column.
|
||||
*/
|
||||
export function asJson<T>(expr: Expression<T>): RawBuilder<T> {
|
||||
return sql<T>`json(${expr})`;
|
||||
}
|
||||
|
||||
function jsonObjectArgs(
|
||||
expr: SelectQueryBuilderExpression<unknown>,
|
||||
table: string,
|
||||
) {
|
||||
const args: Expression<unknown>[] = [];
|
||||
|
||||
for (const { selection } of expr.toOperationNode().selections ?? []) {
|
||||
const name = selectionOutputName(selection);
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
"jsonArrayFrom and jsonObjectFrom can only handle explicit selections. selectAll() is not allowed in the subquery.",
|
||||
);
|
||||
}
|
||||
|
||||
const ref = sql.ref(`${table}.${name}`);
|
||||
|
||||
args.push(
|
||||
sql.lit(name),
|
||||
jsonValuedSelection(selection) ? sql`json(${ref})` : ref,
|
||||
);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
|
|
|||
10
biome-plugins/no-kysely-sqlite-helpers.grit
Normal file
10
biome-plugins/no-kysely-sqlite-helpers.grit
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
language js
|
||||
|
||||
// The kysely sqlite json helpers embed nested JSON documents as escaped strings
|
||||
// (SQLite's JSON subtype does not survive a subquery boundary) and rely on a
|
||||
// result-walking parse plugin this codebase no longer has. The forks in
|
||||
// `app/utils/kysely.server.ts` re-tag nested JSON values with `json()` so the
|
||||
// dialect's single-pass per-column parsing sees fully nested documents.
|
||||
`"kysely/helpers/sqlite"` as $source where {
|
||||
register_diagnostic(span=$source, message="Do not import from `kysely/helpers/sqlite`. Use the `jsonArrayFrom` / `jsonObjectFrom` / `jsonBuildObject` forks in `~/utils/kysely.server` instead; the kysely versions return nested JSON documents as strings.", severity="error")
|
||||
}
|
||||
|
|
@ -90,6 +90,10 @@
|
|||
{
|
||||
"includes": ["app/db/seed/dev/**", "app/db/seed/index.ts"],
|
||||
"plugins": ["./biome-plugins/no-raw-db-writes-in-dev-seed.grit"]
|
||||
},
|
||||
{
|
||||
"includes": ["**", "!app/utils/kysely.server.ts"],
|
||||
"plugins": ["./biome-plugins/no-kysely-sqlite-helpers.grit"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,6 @@ export default defineConfig((config) => {
|
|||
"i18next-browser-languagedetector",
|
||||
"i18next-http-backend",
|
||||
"kysely",
|
||||
"kysely/helpers/sqlite",
|
||||
"markdown-to-jsx",
|
||||
"mediabunny",
|
||||
"nanoid",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user