Read source columns from pg_catalog, not information_schema

information_schema.columns omits materialized views -- they are not in the
SQL standard -- and gs.osm_skinny is one. So the source this app runs on
looked like it had no columns at all: creating a version failed with "No
usable columns in col_meta" while col_meta plainly held thirty-six, and
registering such a source would have seeded nothing.

RELATION_COLUMNS_SQL returns the same shape information_schema did, so
mapType and every caller are unchanged: data_type is format_type with the
modifier stripped, which spells things the same way ('character varying',
'numeric'), and precision and scale are unpacked from atttypmod as
information_schema does internally. Verified against the live matview -- 36
usable columns, and the types map to exactly what fc_osm_skinny_29 already
has.

The table browser had the same blind spot from information_schema.tables and
now lists from pg_class by relkind, so a materialized view can be registered
rather than merely used by a source registered when it was still a table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 16:12:05 -04:00
parent 469e4cc957
commit 23032a0658
5 changed files with 76 additions and 28 deletions

View File

@ -353,6 +353,22 @@ effects call the API immediately.
`pf_user: 'admin'`, which any client could have set to anything. The audit log `pf_user: 'admin'`, which any client could have set to anything. The audit log
now names the account that made the change. now names the account that made the change.
## Source columns come from pg_catalog
`information_schema.columns` omits **materialized views** — they are not in the
SQL standard — and `gs.osm_skinny` is one. So the source the whole app is built
on looked like it had no columns: registering it seeded nothing, and creating a
version failed with "No usable columns in col_meta" while col_meta plainly held
thirty-six.
`RELATION_COLUMNS_SQL` in `lib/utils.js` is the replacement, used by version
creation, source registration and the table preview. It returns the same shape
information_schema did, so `mapType` and the callers were unchanged:
`data_type` is `format_type` with the modifier stripped, which gives the same
spelling (`character varying`, `numeric`), and precision and scale are unpacked
from `atttypmod`. The table browser lists from `pg_class` by `relkind` for the
same reason.
## Territory scoping ## Territory scoping
An account sees and changes only its own territory. The list lives on An account sees and changes only its own territory. The list lives on

View File

@ -3,7 +3,41 @@ function fcTable(tname, versionId) {
return `pf.fc_${tname}_${versionId}`; return `pf.fc_${tname}_${versionId}`;
} }
// map information_schema data_type to a clean postgres column type // The columns of a relation, from pg_catalog rather than information_schema.
//
// information_schema.columns omits materialized views -- they are not in the
// SQL standard -- so a source built on one looked like it had no columns at
// all: registering it seeded nothing, and creating a version failed with "No
// usable columns in col_meta" while col_meta plainly had thirty-six.
//
// The shape matches what information_schema returned, so mapType and the
// callers did not have to change. data_type is format_type with the modifier
// stripped, which gives the same spelling information_schema uses ('character
// varying', 'numeric'), and the numeric precision and scale are unpacked from
// atttypmod the way information_schema does internally.
//
// Takes $1 = schema, $2 = relation name.
const RELATION_COLUMNS_SQL = `
SELECT a.attname AS column_name
,regexp_replace(format_type(a.atttypid, a.atttypmod), '\\(.*\\)$', '') AS data_type
,a.attnum AS ordinal_position
,CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable
,CASE WHEN a.atttypid = 'numeric'::regtype AND a.atttypmod > 4
THEN ((a.atttypmod - 4) >> 16) & 65535 END AS numeric_precision
,CASE WHEN a.atttypid = 'numeric'::regtype AND a.atttypmod > 4
THEN (a.atttypmod - 4) & 65535 END AS numeric_scale
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE TRUE
AND n.nspname = $1
AND c.relname = $2
AND c.relkind IN ('r', 'v', 'm', 'f', 'p')
AND a.attnum > 0
AND NOT a.attisdropped
`;
// map a data_type name to a clean postgres column type
function mapType(dataType, numericPrecision, numericScale) { function mapType(dataType, numericPrecision, numericScale) {
switch (dataType) { switch (dataType) {
case 'character varying': case 'character varying':
@ -36,4 +70,4 @@ function mapType(dataType, numericPrecision, numericScale) {
} }
} }
module.exports = { fcTable, mapType }; module.exports = { fcTable, mapType, RELATION_COLUMNS_SQL };

View File

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const { generateSQL, buildTerritoryClause } = require('../lib/sql_generator'); const { generateSQL, buildTerritoryClause } = require('../lib/sql_generator');
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
const { sessionTerritory } = require('../lib/auth'); const { sessionTerritory } = require('../lib/auth');
const { sessionUser } = require('../lib/auth'); const { sessionUser } = require('../lib/auth');
@ -42,15 +43,14 @@ module.exports = function(pool) {
); );
const source = src.rows[0]; const source = src.rows[0];
// seed col_meta from information_schema // seed col_meta from the source's real columns
await client.query(` await client.query(`
INSERT INTO pf.col_meta (source_id, cname, role, opos) INSERT INTO pf.col_meta (source_id, cname, role, opos)
SELECT $1, column_name, 'dimension', ordinal_position SELECT $3, column_name, 'dimension', ordinal_position
FROM information_schema.columns FROM (${RELATION_COLUMNS_SQL}) c
WHERE table_schema = $2 AND table_name = $3
ORDER BY ordinal_position ORDER BY ordinal_position
ON CONFLICT (source_id, cname) DO NOTHING ON CONFLICT (source_id, cname) DO NOTHING
`, [source.id, schema, tname]); `, [schema, tname, source.id]);
await client.query('COMMIT'); await client.query('COMMIT');
res.status(201).json(source); res.status(201).json(source);

View File

@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
module.exports = function(pool) { module.exports = function(pool) {
const router = express.Router(); const router = express.Router();
@ -7,15 +8,18 @@ module.exports = function(pool) {
router.get('/tables', async (req, res) => { router.get('/tables', async (req, res) => {
try { try {
const result = await pool.query(` const result = await pool.query(`
-- pg_class, not information_schema.tables, which omits
-- materialized views: gs.osm_skinny is one, and the browser
-- could not offer what the app is already built on.
SELECT SELECT
t.table_schema AS schema, n.nspname AS schema,
t.table_name AS tname, c.relname AS tname,
c.reltuples::bigint AS row_estimate c.reltuples::bigint AS row_estimate
FROM information_schema.tables t FROM pg_class c
LEFT JOIN pg_namespace n ON n.nspname = t.table_schema JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_class c ON c.relname = t.table_name AND c.relnamespace = n.oid WHERE c.relkind IN ('r', 'v', 'm', 'f', 'p')
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema', 'pf') AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pf')
ORDER BY t.table_schema, t.table_name ORDER BY n.nspname, c.relname
`); `);
res.json(result.rows); res.json(result.rows);
} catch (err) { } catch (err) {
@ -31,12 +35,8 @@ module.exports = function(pool) {
return res.status(400).json({ error: 'Invalid schema or table name' }); return res.status(400).json({ error: 'Invalid schema or table name' });
} }
try { try {
const cols = await pool.query(` const cols = await pool.query(
SELECT column_name, data_type, is_nullable, ordinal_position `${RELATION_COLUMNS_SQL} ORDER BY ordinal_position`, [schema, tname]);
FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
ORDER BY ordinal_position
`, [schema, tname]);
const rows = await pool.query( const rows = await pool.query(
`SELECT * FROM ${schema}.${tname} LIMIT 5` `SELECT * FROM ${schema}.${tname} LIMIT 5`

View File

@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { fcTable, mapType } = require('../lib/utils'); const { fcTable, mapType, RELATION_COLUMNS_SQL } = require('../lib/utils');
const { sessionUser, sessionTerritory } = require('../lib/auth'); const { sessionUser, sessionTerritory } = require('../lib/auth');
const { buildTerritoryClause } = require('../lib/sql_generator'); const { buildTerritoryClause } = require('../lib/sql_generator');
@ -39,8 +39,9 @@ module.exports = function(pool) {
} }
const source = srcResult.rows[0]; const source = srcResult.rows[0];
// fetch col_meta joined to information_schema for data types // col_meta joined to the source's real columns, for the data types
const colResult = await client.query(` const colResult = await client.query(`
WITH cols AS (${RELATION_COLUMNS_SQL})
SELECT SELECT
m.cname, m.cname,
m.role, m.role,
@ -49,14 +50,11 @@ module.exports = function(pool) {
i.numeric_precision, i.numeric_precision,
i.numeric_scale i.numeric_scale
FROM pf.col_meta m FROM pf.col_meta m
JOIN information_schema.columns i JOIN cols i ON i.column_name = m.cname
ON i.table_schema = $2 WHERE m.source_id = $3
AND i.table_name = $3
AND i.column_name = m.cname
WHERE m.source_id = $1
AND m.role NOT IN ('ignore') AND m.role NOT IN ('ignore')
ORDER BY m.opos ORDER BY m.opos
`, [sourceId, source.schema, source.tname]); `, [source.schema, source.tname, sourceId]);
if (colResult.rows.length === 0) { if (colResult.rows.length === 0) {
return res.status(400).json({ return res.status(400).json({