Перейти до вмісту

Основи безпеки

Supabase "multiple (or no) rows returned" майже завжди означає нуль

Supabase повертає PGRST116, "JSON object requested, multiple (or no) rows returned", коли запит нічого не знайшов. Прихований рядок має вигляд відсутнього.

Vlad Tkachenko9 хв читання
Бланк замовлення і порожній лоток видачі на стійці, а позаду полиця з одним рядком, виділеним бірюзовим, за бурштиновою ґраткою.

Коротко

  • "JSON object requested, multiple (or no) rows returned" це помилка PGRST116, і вона означає, що ваш запит повернувся з кількістю рядків, відмінною від одного. Майже завжди ця кількість нульова.
  • З увімкненою Row Level Security рядок, який ваш відвідувач не має права читати, повертається як жодного рядка. Тож запис, який ви бачите в Supabase Table Editor, може бути відсутнім у вашому застосунку, хоча з даними все гаразд.
  • Беріть maybeSingle() там, де нічого не знайти є законною відповіддю, і single() там, де не є. Спершу виконайте один запит у SQL Editor, щоб знати, який із двох випадків у вас.

Ваш застосунок каже комусь, що запис не знайдено. Ви відкриваєте Supabase Table Editor, і запис лежить саме там, у таблиці, з правильним id.

Ось що повернулося з бази:

{
  "code": "PGRST116",
  "message": "JSON object requested, multiple (or no) rows returned",
  "details": "Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row"
}

Ось частина, яку інструкція за інструкцією подає хибно: нуль рядків це відповідь, яку можуть дати ваші власні правила, і читається вона точно як дані, що зникли. Пошукайте це повідомлення, і вам скажуть замінити .single() на .maybeSingle(). Помилка справді припиниться. Про те, чи рядка бракувало, чи ваш відвідувач ніколи не мав права його бачити, це не каже нічого, а ці два випадки лагодять по-різному.

Що означає "JSON object requested, multiple (or no) rows returned"?

Ваш запит попросив у бази рівно один рядок, а в результаті була інша кількість. Майже завжди ця кількість нульова.

Уявіть читальну залу, де ви заповнюєте бланк, а хтось за стійкою йде по книжку. На вашому бланку написано: принесіть одну книжку з цим номером. Стійка повертається і каже, що за цим бланком нічого немає. Це єдина відповідь, яку дає стійка, і вона однакова, чи книжки ніколи не було в каталозі, чи вона стоїть на полиці, до якої у вас немає допуску. Ніхто з вами не грається. Систему бланків збудували так, щоб те, до чого у вас немає допуску, просто не з'являлося у вашому погляді на каталог.

Бланк тримає саме ваш застосунок, а кількість, з якою повернулася стійка, записана в рядку details відповіді.

Два різні повідомлення, і той самий код помилки

Код сталий, а речення ні, тож орієнтуйтеся на PGRST116.

Для цієї однієї помилки в обігу є два формулювання, і яке ви побачите, залежить від того, яку версію PostgREST тримає ваш проєкт. PostgREST це сервер, який перетворює ваші таблиці Supabase на HTTP API, з яким розмовляє ваш застосунок. Його документація для версії 12 подає повідомлення JSON object requested, multiple (or no) rows returned, із рядком detailsResults contain 0 rows, application/vnd.pgrst.object+json requires 1 row. У версіях 13 і 14 ту саму ситуацію задокументовано як Cannot coerce the result to a single JSON object, а details читається як The result contains 0 rows.

Старіше речення має друге життя поза старими проєктами. Коли ви кличете .maybeSingle() і збігається більше ніж один рядок, JavaScript-клієнт Supabase пише помилку сам, замість передати її від сервера, і повідомлення, яке він пише, це старіше формулювання. Тож перше речення досі приходить у застосунки, база яких надіслала б друге.

Не рухається код. Таблиця помилок PostgREST подає PGRST116 під 406 в усіх трьох версіях, з тим самим описом: більше ніж один рядок або жодного, коли просили рівно один. Читайте поле code і не зважайте на текст.

Чому .single() це взагалі створює

Бо .single() це вказівка про форму відповіді, а не фільтр по даних.

Коли ви пишете .single(), клієнт Supabase додає до запиту один заголовок: Accept: application/vnd.pgrst.object+json. Цей заголовок просить базу надіслати рядок окремо, як запис. Саме він дозволяє вашому коду писати data.name там, де інакше довелося б писати data[0].name.

База дотримується цього суворо. Один рядок іде назад як запис. Будь-яку іншу кількість рядків у такій формі надіслати неможливо, тож відповідь це 406 і об'єкт помилки. Приберіть .single(), і той самий запит поверне [] із 200 і без жодної помилки.

Тож ніщо не мусило зламатися, щоб це сталося. Порожній результат став помилкою, бо ваш запит сказав, що відповідь має містити рівно один запис.

Причина 1: рядок на місці, а ваші правила кажуть ні

Row Level Security не відмовляє вашому запиту. Вона виймає з нього рядки, а вийнятий рядок ніколи не був у результаті, який рахують.

Row Level Security це набір правил на кожній таблиці Supabase, який рядок за рядком вирішує, які рядки дозволено читати конкретному відвідувачеві. Коли жодне правило не дозволяє вашому відвідувачеві прочитати рядок, названий вашим запитом, цей рядок прибирають ще до вимірювання результату. Кількість нульова. Ваш .single() хотів один. Ви отримуєте PGRST116, і жодна частина відповіді не згадує, що в справі було правило.

Це знову читальна зала, і саме тому стійка двічі каже те саме. Ваш допуск застосовують ще до того, як полицю взагалі переглянуть, тож книжка, якої вам не можна брати, і книжка, якої немає, дають назад однаковий бланк.

Рядок, прихований вашими правилами, і рядок, якого немає, однаково лишають результат порожнім. Відповідь рахує рядки, тож обидва приходять як та сама помилка.

Саме тому Table Editor вас і збиває. SQL Editor і Table Editor у вашій панелі Supabase працюють з правами, на які ваші Policy не поширюються, тож вони показують вам кожен рядок. Запит вашого застосунку несе ваш publishable key і сесію вашого відвідувача, і на нього Policy поширюються. Два погляди на одну таблицю, і лише один із них отримують ваші користувачі.

Цю різницю видно ззовні, і саме так ми можемо її порахувати. Наш сканер читає живі застосунки так, як це робив би незнайомець. За 8 435 застосунками з тих, яким він виставив оцінку, стоїть проєкт Supabase. На 3 680 із них питання "чи може хтось без облікового запису прочитати цю таблицю" отримало придатну відповідь, і 1 584 відповіли ні: незнайомець, що просив рядки, не отримав нічого. Це ніщо і є те саме ніщо, яке отримує ваш власний застосунок, коли жодна Policy йому не відповідає.

Як відрізнити прихований рядок від відсутнього

Виконайте той самий пошук двічі, один раз там, де Policy діють, і один раз там, де не діють. Один запит це вирішує.

  1. Відкрийте панель Supabase, перейдіть у SQL Editor і візьміть рядок за тим самим значенням, яке використав ваш застосунок: select * from your_table where id = 'the-id-your-app-sent';. Запит із SQL Editor іде через роль postgres, на яку ваші Policy не поширюються.
  2. Якщо рядок повернувся, він існує, і щось між вашим застосунком і рядком його прибрало. Це щось є Policy, і ви в причині 1. Прочитайте Policy на тій таблиці.
  3. Якщо й там нічого не повернулося, рядка справді немає, і ви в причині 3 нижче. З вашими правилами все гаразд.

Зробіть це перед тим, як змінювати бодай рядок коду. Обидві причини дають той самий об'єкт помилки, а два ремонти не мають між собою нічого спільного.

Поки ви дивитеся на Policy тієї таблиці, варто знати, що вони кажуть сьогодні комусь зовсім без облікового запису. Наше безкоштовне сканування читає ваш живий застосунок і каже, які з ваших таблиць відповідають незнайомцеві. Воно триває близько 20 секунд і не потребує облікового запису: просканувати застосунок.

Причина 2: повернулося кілька рядків

.single() використали на пошуку, який може збігтися більше ніж з одним рядком.

Пошук за первинним ключем збігається з одним рядком або з жодним, за визначенням. Пошук за поштою, за slug, за іменем, за статусом, за зовнішнім ключем: будь-який із них може зачепити два. Звична передісторія в тому, що колонка тримала одне значення на рядок у ваших тестових даних і перестала це робити, коли прийшли справжні люди.

Відповідь каже вам, у якій ви причині. Обидва формулювання рядка details називають, скільки рядків містив результат. Нуль відсилає вас до причини 1 або причини 3. Число понад нуль означає цю, і це кількість рядків, що збіглися.

Ремонт це не .maybeSingle(). Він приймає нуль рядків і далі дає помилку на двох, тож тут нічого не змінює. Або звужуйте запит до одного рядка колонкою, яка справді унікальна, або прийміть, що їх кілька, і читайте список.

Причина 3: рядка справді немає

Іноді рядка бракує, і запит із попереднього розділу це підтверджує.

Звичайні причини, у порядку, в якому вони трапляються у застосунку з vibe coding. Значення, за яким фільтрував ваш запит, було порожнє, бо id ще не завантажився, коли запит пішов, тож нічого не збіглося. Рядок видалили, ви самі або промпт, який переписав більше, ніж ви чекали. Або запит називає таблицю, яку перебудували під іншою назвою, і старі рядки пішли зі старою таблицею.

Нічого з цього не є проблемою безпеки, і жодне не лагодиться зміною Policy. Якщо SQL Editor нічого не знаходить, припиніть читати про Row Level Security і подивіться, що передає ваш застосунок.

single чи maybeSingle: чого хоче ваш запит

.maybeSingle() там, де нічого не знайти є законною відповіддю. .single() там, де не є.

.single() вважає будь-яку кількість рядків, крім одного, помилкою. Це правильна поведінка для пошуку за первинним ключем, де нуль рядків означає, що запису, про який ідеться у вашому коді, не існує, і йти далі було б гірше, ніж зупинитися.

.maybeSingle() приймає нуль рядків: він віддає вам data як null, без помилки і з 200. Більше ніж один рядок далі є помилкою, з тим самим кодом PGRST116. Тож він пасує рядкові профілю, якого могли ще не створити, або необов'язковому запису, для якого ваша сторінка малює порожній стан.

Уся різниця в тому, що кожен із них робить із порожнім результатом. Два рядки відхиляють обидва, і жоден із них не змінює того, що насправді повернула база.

Одна річ, з якою треба бути обережним. Заміна .single() на .maybeSingle() змінює поведінку вашого застосунку і не змінює нічого в тому, що надіслала база. Якщо рядок ховала Policy, ви перетворили видиму помилку на сторінку, що малюється так, ніби запису не існує, і саме для тієї однієї людини, якій його треба було побачити. Помилка вам щось казала. Робіть заміну після перевірки вище, коли знаєте, що нуль це чесна відповідь.

Пильнувати свої правила після того, як ви їх змінили

Policy, яку ви розширюєте, щоб позбутися цієї помилки, це зміна, якої зсередини вашого застосунку ніхто не бачить. Ваша власна сесія працює далі, помилки немає, а чи відповідає тепер та таблиця незнайомцеві, хтось мусить піти й запитати.

Reeve Monitor питає це за вас:

  • усі дев'ять перевірок щогодини, для трьох застосунків
  • лист того дня, коли повторне сканування оцінить ваш застосунок нижче за попереднє
  • чи відповідає застосунок, кожні 60 секунд
  • місячний звіт про побачене

Правило, послаблене настільки, щоб прибрати помилку, може дати незнайомцеві не лише читати рядки, а й міняти їх. Reeve Care тримає копію вашої бази Supabase там, куди жодна зміна ваших правил не дістане.

  • зашифрована копія щодня, збережена поза вашим обліковим записом Supabase
  • кожна копія перевірена, перш ніж її зарахувати, підрахунком рядків у кожній таблиці
  • відновлення одним кліком, яке зберігає те, що є зараз, перш ніж замінити
  • ваші завантажені файли теж, щойно ви під'єднаєте доступ до Storage
  • усе, що робить Monitor

Що зробити сьогодні

Що робити

  • Орієнтуйтеся на поле code. PGRST116 приходить під двома різними реченнями, і яке дістанеться вам, залежить від версії PostgREST у вашому проєкті.
  • Читайте кількість рядків у рядку details. Нуль означає порожній результат. Понад нуль означає, що ваш пошук зачепив кілька рядків і потребує унікальної колонки.
  • На порожньому результаті виконайте той самий пошук у SQL Editor, перш ніж міняти код. Якщо рядок повертається там і не повертається у застосунку, його прибрала Policy.
  • Переходьте на .maybeSingle(), коли знаєте, що нуль це чесна відповідь. На рядку, який ховає Policy, він замінює помилку порожньою сторінкою, без попередження.
  • Після того як ви змінили Policy, щоб цього позбутися, перевірте, що та таблиця тепер повертає комусь без облікового запису.

Почніть з одного запиту в SQL Editor, бо саме він вирішує, яка половина цієї статті вас стосується. Якщо виявиться, що відповіддю була Policy, увімкнути Row Level Security на кожній таблиці показує, як написати правило, що пропускає потрібну людину, а "new row violates row-level security policy" це повідомлення, яке ви отримуєте, коли Policy відмовляє в записі, що його ви бачите на власні очі.

Поширені запитання

Що означає PGRST116?

Це означає, що ваш запит попросив у бази рівно один рядок, а в результаті була інша кількість. PostgREST, частина Supabase, яка відповідає вашому застосунку через HTTP, повертає це як HTTP 406 із кодом PGRST116. Кількість стоїть у рядку details відповіді, і майже завжди вона нульова.

Чому мій запит не повертає жодного рядка, хоча я бачу рядок у Supabase?

Бо SQL Editor у вашій панелі працює з правами, на які ваші Policy Row Level Security не поширюються, а ваш застосунок ні. Якщо жодна Policy не дозволяє вашому відвідувачеві читати той рядок, рядок прибирають із його результату ще до підрахунку, і назад приходить нуль. Рядок на місці. Його просто ніколи не було у відповіді.

Яка різниця між single і maybeSingle?

single() вважає будь-яку кількість рядків, крім одного, помилкою. maybeSingle() приймає нуль рядків і віддає вам data як null, без помилки, і далі повідомляє про помилку, коли збігається більше ніж один рядок. Тож maybeSingle() пасує там, де нічого не знайти є законною відповіддю, наприклад для профілю, якого ще немає, а single() пасує для пошуку за колонкою, яка може збігтися лише з одним рядком.

Чи спричиняє цю помилку Row Level Security?

Це найчастіша причина в застосунку, зробленому в Lovable, Bolt, Replit або v0. Row Level Security відфільтровує рядки з результату, а не відмовляє в запиті, тож рядок, який нікому не можна читати, не відрізнити від рядка, якого немає. Обидва дають нуль, і запит, що вимагає рівно одного рядка, падає на обох.

Чому це 406?

406 Not Acceptable це статус для запиту, на який сервер не може відповісти у тій формі, яку просили. Ваш запит ніс заголовок Accept, який казав, що відповідь має бути одним записом, а не списком, і без жодного рядка всередині немає прийнятної відповіді, яку можна надіслати. Приберіть той заголовок, і той самий запит поверне порожній список із 200.

Автор

Vlad Tkachenko

Засновник Reeve

Я щодня дивлюся на застосунки, зібрані в Lovable, Bolt, v0, Cursor і Replit, і на короткий список помилок, які трапляються в них знову і знову.

Більше про автора

Читати далі

Усі статті

Зробіть резервну копію бази Supabase перед наступною зміною

Reeve за розкладом зберігає зашифровану копію і відновить її за вас, якщо щось зламається.

Резервні копії Supabase

Автоматична зовнішня перевірка, а не повний аудит. Відсутність знахідок не є гарантією безпеки.