Vai al contenuto

Basi della sicurezza

Supabase "multiple (or no) rows returned" quasi sempre è nessuna

Supabase restituisce PGRST116, "JSON object requested, multiple (or no) rows returned", quando la query non ha trovato nulla. Una riga nascosta sembra assente.

Vlad Tkachenko11 min di lettura
Un modulo di richiesta e un vassoio di riconsegna vuoto su un bancone, e dietro uno scaffale con una riga in teal dietro una barra ambra.

In breve

  • "JSON object requested, multiple (or no) rows returned" è l'errore PGRST116, e significa che la tua query è tornata con un numero di righe diverso da uno. Quasi sempre quel numero è zero.
  • Con Row Level Security attiva, una riga che il tuo visitatore non può leggere torna come nessuna riga. Così un record che vedi nel Table Editor di Supabase può mancare nella tua app mentre i dati stanno benissimo.
  • Usa maybeSingle() dove non trovare nulla è una risposta legittima e single() dove non lo è. Prima lancia una query nel SQL Editor, per sapere quale dei due casi hai.

La tua app dice a qualcuno che un record non si trova. Apri il Table Editor di Supabase e il record sta lì, nella tabella, con l'id giusto.

Questo è quello che è tornato dal database:

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

Ed ecco la parte che guida dopo guida sbaglia: zero righe è una risposta che le tue stesse regole sanno produrre, e si legge esattamente come dei dati spariti. Cerca questo messaggio e ti diranno di sostituire .single() con .maybeSingle(). L'errore si ferma davvero. Non ti dice niente sul fatto che la riga mancasse o che il tuo visitatore non avesse mai il permesso di vederla, e questi due casi si riparano in modo diverso.

Che cosa significa "JSON object requested, multiple (or no) rows returned"?

La tua query ha chiesto al database esattamente una riga e il risultato ne conteneva un altro numero. Quasi sempre quel numero è zero.

Pensa a una sala di lettura dove compili un modulo e qualcuno dietro il bancone va a prendere il libro. Il tuo modulo dice: portami l'unico libro con questo numero. Il bancone torna e ti dice che non c'è niente che corrisponda al modulo. Quella è l'unica risposta che il bancone dà, ed è la stessa sia che il libro non sia mai stato a catalogo, sia che stia su uno scaffale per cui non hai l'autorizzazione. Nessuno sta giocando con te. Il sistema dei moduli è stato costruito perché ciò per cui non hai l'autorizzazione sia semplicemente assente dalla tua vista del catalogo.

È la tua app a tenere il modulo, e il numero con cui il bancone è tornato è scritto nella riga details della risposta.

Due messaggi diversi, e lo stesso codice di errore

Il codice è stabile e la frase no, quindi regolati su PGRST116.

Per questo singolo errore circolano due formulazioni, e quale vedi dipende da quale versione di PostgREST gira il tuo progetto. PostgREST è il server che trasforma le tue tabelle Supabase nell'API HTTP con cui parla la tua app. La sua documentazione per la versione 12 dà il messaggio JSON object requested, multiple (or no) rows returned, con una riga details che dice Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row. Nelle versioni 13 e 14 la stessa situazione è documentata come Cannot coerce the result to a single JSON object, con details che dice The result contains 0 rows.

La frase più vecchia ha una seconda vita oltre i progetti vecchi. Quando chiami .maybeSingle() e corrisponde più di una riga, il client JavaScript di Supabase scrive l'errore da sé invece di passarne uno dal server, e il messaggio che scrive è la formulazione vecchia. Quindi la prima frase arriva ancora in app il cui database avrebbe mandato la seconda.

Quello che non si muove è il codice. La tabella degli errori di PostgREST elenca PGRST116 sotto 406 in tutte e tre le versioni, con la stessa descrizione: più di una riga, o nessuna, quando ne era richiesta esattamente una. Leggi il campo code e ignora la prosa.

Perché .single() produce tutto questo

Perché .single() è un'istruzione sulla forma della risposta e non un filtro sui dati.

Quando scrivi .single(), il client di Supabase aggiunge un header alla richiesta: Accept: application/vnd.pgrst.object+json. Quell'header chiede al database di mandare la riga da sola, come record. È quello che permette al tuo codice di scrivere data.name dove altrimenti dovrebbe scrivere data[0].name.

Il database lo rispetta alla lettera. Una riga torna come record. Qualsiasi altro numero di righe non si può mandare in quella forma, quindi la risposta è un 406 e un oggetto errore. Togli il .single() e la query identica restituisce [] con un 200 e nessun errore da nessuna parte.

Quindi non è servito che si rompesse niente per produrre questo. Un risultato vuoto è diventato un errore perché la tua query diceva che la risposta doveva contenere esattamente un record.

Causa 1: la riga c'è e le tue regole dicono di no

Row Level Security non rifiuta la tua query. Le toglie delle righe, e una riga tolta non è mai stata nel risultato che viene contato.

Row Level Security è l'insieme di regole su ogni tabella Supabase che decide, riga per riga, quali righe un dato visitatore può leggere. Quando nessuna regola consente al tuo visitatore di leggere la riga che la tua query ha nominato, quella riga viene tolta prima che il risultato sia misurato. Il conteggio è zero. Il tuo .single() ne voleva una. Ottieni PGRST116, e nessuna parte della risposta accenna al fatto che ci fosse di mezzo una regola.

È di nuovo la sala di lettura, ed è per questo che il bancone dice due volte la stessa cosa. La tua autorizzazione viene applicata prima ancora che lo scaffale sia cercato, quindi un libro che non puoi prendere e un libro che non esiste producono un modulo identico che torna indietro.

Una riga nascosta dalle tue regole e una riga che non c'è lasciano il risultato vuoto allo stesso modo. La risposta conta righe, quindi le due arrivano come lo stesso errore.

È anche per questo che il Table Editor ti inganna. Il SQL Editor e il Table Editor della tua console Supabase girano con privilegi a cui le tue policy non si applicano, quindi ti mostrano ogni riga. La richiesta della tua app porta la tua publishable key e la sessione del tuo visitatore, e a lei le policy si applicano. Due viste di una stessa tabella, e solo una è quella che ricevono i tuoi utenti.

Questa differenza si legge da fuori, ed è così che possiamo contarla. Il nostro scanner legge le app in rete come farebbe uno sconosciuto. Dietro 8.435 delle app che ha valutato c'è un progetto Supabase. Su 3.680 di quelle la domanda "qualcuno senza account può leggere questa tabella" ha avuto una risposta utilizzabile, e 1.584 hanno risposto di no: uno sconosciuto che chiedeva le righe non ha ricevuto niente. Quel niente è lo stesso niente che riceve la tua app quando nessuna policy le corrisponde.

Come distinguere una riga nascosta da una che manca

Lancia la stessa ricerca due volte, una dove le policy si applicano e una dove non si applicano. Una query chiude la questione.

  1. Apri la tua console Supabase, vai nel SQL Editor e prendi la riga con lo stesso valore che ha usato la tua app: select * from your_table where id = 'the-id-your-app-sent';. Una query dal SQL Editor gira attraverso il ruolo postgres, a cui le tue policy non si applicano.
  2. Se restituisce la riga, la riga esiste e qualcosa tra la tua app e la riga l'ha tolta. Quel qualcosa è una policy, e sei nella causa 1. Leggi le policy di quella tabella.
  3. Se non restituisce niente nemmeno lì, la riga manca davvero e sei nella causa 3 più sotto. Le tue regole non c'entrano.

Fallo prima di cambiare una riga di codice. Le due cause producono lo stesso oggetto errore, e le due riparazioni non hanno niente in comune.

Già che guardi le policy di quella tabella, vale la pena sapere che cosa dicono oggi a qualcuno senza nessun account. La nostra scansione gratuita legge la tua app in rete e ti dice quali delle tue tabelle rispondono a uno sconosciuto. Dura una ventina di secondi e non richiede un account: scansiona la tua app.

Causa 2: sono tornate più righe

.single() è stato usato su una ricerca che può corrispondere a più di una riga.

Una ricerca per chiave primaria corrisponde a una riga o a nessuna, per definizione. Una ricerca per email, per slug, per nome, per stato, per chiave esterna: ognuna di queste può toccarne due. La storia solita è che la colonna aveva un valore per riga nei tuoi dati di prova e ha smesso di averlo quando sono arrivate persone vere.

La risposta ti dice in quale causa sei. Entrambe le formulazioni della riga details dicono quante righe conteneva il risultato. Zero ti manda alla causa 1 o alla causa 3. Un numero sopra lo zero indica questa, ed è il conteggio delle righe che hanno corrisposto.

La riparazione non è .maybeSingle(). Accetta zero righe e continua a dare errore con due, quindi qui non cambia niente. O restringi la query a una riga con la colonna che è davvero unica, o accetti che ce ne siano diverse e leggi una lista.

Causa 3: la riga è sparita davvero

A volte la riga manca, e la query della sezione precedente è ciò che lo conferma.

I motivi ordinari, nell'ordine in cui saltano fuori in un'app fatta in vibe coding. Il valore su cui filtrava la tua query era vuoto, perché l'id non era caricato quando la query è partita, quindi non ha corrisposto niente. La riga è stata cancellata, da te o da un prompt che ha riscritto più del previsto. Oppure la query nomina una tabella ricostruita con un altro nome e le righe vecchie sono andate con la tabella vecchia.

Niente di tutto questo è un problema di sicurezza, e nessuno si risolve cambiando una policy. Se il SQL Editor non trova niente, smetti di leggere di Row Level Security e vai a vedere che cosa passa la tua app.

single o maybeSingle: quale vuole la tua query

.maybeSingle() dove non trovare nulla è una risposta legittima. .single() dove non lo è.

.single() tratta qualsiasi numero di righe diverso da uno come un errore. È il comportamento giusto per una ricerca per chiave primaria, dove zero righe vuol dire che il record di cui parla il tuo codice non esiste e andare avanti sarebbe peggio che fermarsi.

.maybeSingle() accetta zero righe: ti restituisce data come null, senza errore e con un 200. Più di una riga resta un errore, con lo stesso codice PGRST116. Quindi va bene per una riga di profilo che potrebbe non essere ancora stata creata, o per un record facoltativo per cui la tua pagina disegna uno stato vuoto.

L'unica differenza sta in che cosa ciascuno fa di un risultato vuoto. Tutti e due rifiutano due righe, e nessuno dei due cambia quello che il database ha davvero restituito.

Una cosa a cui fare attenzione. Sostituire .single() con .maybeSingle() cambia come si comporta la tua app e non cambia niente di quello che il database ha mandato. Se una policy stava nascondendo la riga, hai trasformato un errore visibile in una pagina che si disegna come se il record non esistesse, proprio per l'unica persona che doveva vederlo. L'errore ti stava dicendo qualcosa. Fai lo scambio dopo la prova qui sopra, quando sai che zero è la risposta onesta.

Tenere d'occhio le tue regole dopo averle cambiate

Una policy che allarghi per togliere di mezzo questo errore è un cambiamento che da dentro la tua app non vede nessuno. La tua sessione continua a funzionare, l'errore è sparito, e se quella tabella adesso risponde a uno sconosciuto è una domanda che qualcuno deve andare a fare.

Reeve Monitor la fa per te:

  • tutti e nove i controlli ogni ora, su un massimo di tre app
  • un'email il giorno in cui una nuova scansione valuta la tua app peggio della precedente
  • se l'app risponde, ogni 60 secondi
  • un rapporto mensile di quello che ha visto

Una regola allentata quanto basta per togliere un errore può lasciare che uno sconosciuto cambi le righe oltre che leggerle. Reeve Care tiene una copia del tuo database Supabase dove nessun cambiamento alle tue regole può arrivare.

  • una copia cifrata ogni giorno, tenuta fuori dal tuo account Supabase
  • ogni copia verificata prima di contare, contando le righe di ogni tabella
  • un ripristino in un clic che salva quello che c'è prima di sostituirlo
  • anche i tuoi file caricati, appena colleghi un accesso Storage
  • tutto quello che fa Monitor

Che cosa fare oggi

Cosa fare

  • Regolati sul campo code. PGRST116 arriva sotto due frasi diverse, e quale ti tocca dipende dalla versione di PostgREST che gira il tuo progetto.
  • Leggi il conteggio di righe nella riga details. Zero vuol dire risultato vuoto. Sopra lo zero, la tua ricerca ha toccato più righe e serve una colonna unica.
  • Su un risultato vuoto, lancia la stessa ricerca nel SQL Editor prima di cambiare codice. Se la riga torna lì e non nella tua app, una policy l'ha tolta.
  • Passa a .maybeSingle() quando sai che zero è la risposta onesta. Su una riga che una policy nasconde, sostituisce un errore con una pagina vuota, senza avvisare.
  • Dopo aver cambiato una policy per togliere questo, controlla che cosa restituisce adesso quella tabella a qualcuno senza account.

Comincia con una query nel SQL Editor, perché è lei a decidere quale metà di questo articolo ti riguarda. Se viene fuori che la risposta era una policy, attivare Row Level Security su ogni tabella mostra come scrivere una regola che lascia passare la persona giusta, e "new row violates row-level security policy" è il messaggio che ricevi quando una policy rifiuta una scrittura che vedi con i tuoi occhi.

FAQ

Che cosa significa PGRST116?

Significa che la tua query ha chiesto al database esattamente una riga e il risultato ne conteneva un altro numero. PostgREST, la parte di Supabase che risponde alla tua app via HTTP, lo restituisce come HTTP 406 con il codice PGRST116. Il numero sta nella riga details della risposta, e quasi sempre è zero.

Perché la mia query non restituisce righe se vedo la riga in Supabase?

Perché il SQL Editor della tua console gira con privilegi a cui le tue policy Row Level Security non si applicano, e la tua app no. Se nessuna policy consente al tuo visitatore di leggere quella riga, la riga viene tolta dal suo risultato prima che il risultato venga contato, e quello che torna è uno zero. La riga c'è. Non è mai stata nella risposta.

Qual è la differenza tra single e maybeSingle?

single() tratta qualsiasi numero di righe diverso da uno come un errore. maybeSingle() accetta zero righe e ti restituisce data come null, senza errore, e continua a dare errore quando più di una riga corrisponde. Quindi maybeSingle() va bene dove non trovare nulla è una risposta legittima, come un profilo che ancora non esiste, e single() va bene per una ricerca su una colonna che può corrispondere a una sola riga.

È Row Level Security a causare questo errore?

È la causa più comune in un'app costruita con Lovable, Bolt, Replit o v0. Row Level Security filtra le righe fuori dal risultato invece di rifiutare la richiesta, quindi una riga che nessuno può leggere è indistinguibile da una riga che non esiste. Entrambe danno un conteggio di zero, e una query che pretende esattamente una riga fallisce su entrambe.

Perché è un 406?

406 Not Acceptable è lo stato di una richiesta a cui il server non può rispondere nella forma richiesta. La tua query portava un header Accept che diceva che la risposta doveva essere un record singolo e non una lista, e senza righe da metterci dentro non c'è nessuna risposta accettabile da mandare. Togli quell'header e la stessa query restituisce una lista vuota con un 200.

Scritto da

Vlad Tkachenko

Fondatore di Reeve

Passo le giornate a guardare app costruite con Lovable, Bolt, v0, Cursor e Replit, e la breve lista di errori che vi ricompaiono di continuo.

Altro sull'autore

Da leggere dopo

Tutti gli articoli

Fai il backup del database Supabase prima della prossima modifica

Reeve ne tiene una copia cifrata a intervalli programmati e la ripristina per te se qualcosa va storto.

Vedi i backup Supabase

Controllo esterno automatizzato, non un audit completo. L'assenza di risultati non è una garanzia di sicurezza.