openlang

count(*) пришёл строкой, и 12 + 1 дало 121

Экран списка дел. В шапке 12 задач, я добавляю ещё одну и, чтобы поднять число до ответа сервера, делаю total + 1. В шапке теперь 121 задача. (На секунду показалось, что из ниоткуда взялась сотня дел.)

Причина такая. count(*) в PostgreSQL - это bigint, а драйвер pg для Node по умолчанию отдаёт bigint строкой, потому что в число JavaScript помещается не всякое 64-битное целое. А тип строки результата написан вручную.

type CountRow = { total: number };

const { rows } = await pool.query<CountRow>(
  'SELECT count(*) AS total FROM todos WHERE done = false',
);
setTotal(rows[0].total + 1);

Тип говорит number, редактор молчит, а во время выполнения '12' + 1 - это склейка строк.

Вижу два способа. Привести тип в SQL через count(*)::int или разобрать значение в коде. Пока запросов немного, приведение выглядит проще. Где вы это обычно делаете?

Разговор продолжается 7

Следуя доводам
  1. Ссылка на ответ

    Настоящий баг - написанный вручную CountRow, и он остаётся, какой способ ни выбери. pool.query<CountRow> - это утверждение типа с более приятным синтаксисом. Никто не проверил, что total число, дженерик просто велел компилятору в это поверить.

    Для счётчика приведение годится. count(*)::int громко падает с ошибкой выхода за диапазон где-то после двух миллиардов, а список дел туда не доберётся. Но это чинит один столбец, и следующий столбец bigint снова придёт строкой под типом, который утверждает обратное. Маленький парсер на границе закрывает оба случая.

    function toCount(value: unknown): number {
      if (typeof value !== 'string' || !/^\d+$/.test(value)) {
        throw new Error(`not a count: ${String(value)}`);
      }
      const n = Number(value);
      if (!Number.isSafeInteger(n)) {
        throw new Error(`count too large: ${value}`);
      }
      return n;
    }

    Пропустите строку через него и соберите CountRow из того, что он вернул. На такой ранней стадии я бы всё же привела тип в SQL, а парсер добавила, когда понадобится второй запрос.

  2. Ссылка на ответ

    Рядом с этими двумя стоит положить третий вариант. Разобрать в bigint и так и оставить. Тогда total + 1 не скомпилируется, потому что TypeScript не позволяет смешивать bigint и number в арифметике, и придётся написать total + 1n. Это ровно та ошибка, которой не хватало экрану. Цена - во всём остальном. Форматирование, строгое сравнение с обычными числами и всё, что уходит в JSON, требуют преобразования. Для счётчика, который никогда не выйдет из диапазона безопасных целых, number дешевле как представление, и решать это правильно в парсере, который выложила tess.p.

  3. Ссылка на ответ

    Пока выбрано приведение в SQL, как советовала tess.p, count(*)::int. Шапка растёт до 13, как и должна. Но ниже на том же экране есть итог ожидаемых затрат. Это sum(price), а price типа numeric, так что он тоже приходит строкой. Никто не замечал, потому что он только выводится как есть. Уже хотелось навесить на него parseFloat, чтобы он тоже рос при добавлении задачи, и тут рука остановилась. Это же деньги. Так можно?

  4. Ссылка на ответ

    Нельзя. Драйвер отдаёт numeric строкой как раз потому, что значение десятичное, а двоичное число с плавающей точкой нет. parseFloat('19.99') уже приближение, а сумма нескольких таких выводится с длинным хвостом цифр или округляется не в ту сторону на последнем центе. Для показа оставьте строку, база посчитала эту сумму точно. Если экран обязан прибавить что-то до ответа сервера, сначала переведите в целые центы и складывайте целые, а когда придёт цифра сервера, замените ею свою. Денежный столбец складывают там, где объявлен его масштаб.

  5. Ссылка на ответ

    С JSON это вылезет раньше, чем кажется. JSON.stringify бросает TypeError на bigint, так что когда этот итог впервые попадёт в тело ответа или в сохранённое состояние, всё упадёт во время выполнения, а компилятор так ничего и не скажет. Можно дать BigInt.prototype метод toJSON, но тогда каждый bigint в программе сериализуется одинаково, подходит это получателю или нет. Согласна, что здесь правильный выбор number, и преобразование должно жить в парсере, а не в каждом месте, где значение уходит наружу.

  6. Ссылка на ответ

    Ладно, parseFloat убран. Версия с целыми центами так и осталась недоделанной, потому что возник вопрос, нужно ли вообще поднимать оценку затрат до ответа сервера, и ответ вышел отрицательный. Итог затрат теперь меняется, когда приходит ответ, а на полсекунды между ними остаётся старое значение. (Лучше, чем полсекунды неверного числа.) Оптимистичное увеличение осталось только у счётчика.

  7. Ссылка на ответ

    Немного в сторону от типов. Оптимистичное total + 1 предполагает, что с момента подсчёта никто больше задач не добавлял, а если вставка вернёт новый счётчик, догадку через мгновение заменит настоящее число.

Другие стороны того же вопроса

  1. rows.Next вернул false, а ошибка потерялась

    Маленькая утилита на database/sql вернула половину списка. Без ошибки. Код такой. Контекст с дедлайном. Дедлайн истёк посреди…

    Ответы 3sqlgo
  2. UPDATE перепроверяет WHERE после ожидания

    Две сессии под PostgreSQL Read Committed, один счёт с балансом 15, и обе сессии выполняют один и тот же оператор. Сессия A об…

    Ответы 0sql
  3. count(*) OVER () или Repeatable Read для постраничного отчёта

    При Read Committed постраничный отчёт может разойтись сам с собой. Экран отчёта насчитывает 12 подходящих заказов. Другой сеа…

    Ответы 9sql