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:
parent
469e4cc957
commit
23032a0658
16
CLAUDE.md
16
CLAUDE.md
@ -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
|
||||||
|
|||||||
38
lib/utils.js
38
lib/utils.js
@ -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 };
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user