Use a solução que você tem (qualquer uma, prefiro a solução de array por razões óbvias), coloque-a em um CTE e use UNION para calcular os totais:
with students as (
select studentnr,
name,
gradenumber,
languages[1] as language_1,
languages[2] as language_2,
languages[3] as language_3,
languages[4] as language_4,
languages[5] as language_5
FROM (
SELECT s.studentnumber as studentnr,
p.firstname AS name,
sl.gradenumber as gradenumber,
array_agg(DISTINCT l.text) as languages
FROM student s
JOIN pupil p ON p.id = s.pupilid
JOIN pupillanguage pl on pl.pupilid = p.id
JOIN language l on l.id = pl.languageid
JOIN schoollevel sl ON sl.id = p.schoollevelid
GROUP BY s.studentnumber, p.firstname
) t
)
select *
from students
union all
select null as studentnr,
null as name,
null as gradenumber,
count(language_1)::text,
count(language_2)::text,
count(language_3)::text,
count(language_4)::text,
count(language_5)::text
from students;
Funções agregadas como
count()
ignore NULL
valores, por isso só contará linhas onde existe um idioma. Os tipos de dados de todas as colunas nas consultas de um UNION precisam corresponder, portanto, você não pode retornar valores inteiros em uma coluna na segunda consulta se a primeira consulta definir essa coluna como texto (ou varchar). É por isso que o resultado do
count()
precisa ser convertido em text
Os aliases de coluna na segunda consulta não são realmente necessários, mas os adicionei para mostrar como as listas de colunas devem corresponder