Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,11 @@ RETURNING version`,
// snapshot). A snapshot with a version of zero is returned if the docuemnt
// has never been created in the database.
PostgresDB.prototype.getSnapshot = async function(collection, id, fields, options, callback) {
fields ||= {};
options ||= {};
const wantsMetadata = fields.$submit || options.metadata;
const wantsMetadata = fields?.$submit || options.metadata;
const {text, values} = snapshotQuery(collection, id, fields);
try {
const result = await this._pool.query(
'SELECT version, data, doc_type, metadata FROM snapshots WHERE collection = $1 AND doc_id = $2 LIMIT 1',
[collection, id],
);
const result = await this._pool.query(text, values);

var row = result.rows[0]
const snapshot = {
Expand All @@ -140,6 +137,31 @@ PostgresDB.prototype.getSnapshot = async function(collection, id, fields, option
}
};

const SELECT_SNAPSHOT =
'SELECT version, data, doc_type, metadata FROM snapshots WHERE collection = $1 AND doc_id = $2 LIMIT 1';

const SELECT_SNAPSHOT_WITHOUT_DATA = `SELECT version,
CASE WHEN doc_type IS NULL THEN NULL ELSE '{}'::jsonb END AS data,
doc_type, metadata
FROM snapshots WHERE collection = $1 AND doc_id = $2 LIMIT 1`;

const SELECT_SNAPSHOT_KEYS = `SELECT version,
CASE WHEN jsonb_typeof(data) = 'object'
THEN (SELECT coalesce(jsonb_object_agg(key, value), '{}') FROM jsonb_each(data) WHERE key = ANY($3::text[]))
ELSE data
END AS data,
doc_type, metadata
FROM snapshots WHERE collection = $1 AND doc_id = $2 LIMIT 1`;

function snapshotQuery(collection, id, fields) {
if (!fields || fields.$submit) return {text: SELECT_SNAPSHOT, values: [collection, id]};

const keys = Object.keys(fields);
if (!keys.length) return {text: SELECT_SNAPSHOT_WITHOUT_DATA, values: [collection, id]};

return {text: SELECT_SNAPSHOT_KEYS, values: [collection, id, keys]};
}

// Get operations between [from, to) noninclusively. (Ie, the range should
// contain start but not end).
//
Expand Down
117 changes: 117 additions & 0 deletions index.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
const PostgresDB = require('.');
const {Pool} = require('pg');
const fs = require('node:fs');
const {expect} = require('chai');
const sinon = require('sinon');
const Backend = require('sharedb');

const DB_NAME = 'sharedbtest';

Expand All @@ -9,6 +12,12 @@ function create(callback) {
callback(null, db);
};

function promisifyCallback(fn) {
return (...args) => new Promise((resolve, reject) => {
fn(...args, (error, result) => error ? reject(error) : resolve(result));
});
}

describe('PostgresDB', function() {
let pool;
let client;
Expand All @@ -34,4 +43,112 @@ describe('PostgresDB', function() {
});

require('sharedb/test/db')({create: create});

describe('getSnapshot with fields', () => {
let db;
let commit;
let getSnapshot;

beforeEach(async () => {
db = new PostgresDB({database: DB_NAME});
commit = promisifyCallback(db.commit.bind(db));
getSnapshot = promisifyCallback(db.getSnapshot.bind(db));
const snapshot = {type: 'json0', v: 1, data: {x: 5, y: 6}, m: {test: 3}};
await commit('testcollection', 'test', {v: 0, create: {}}, snapshot, null);
});

afterEach(async () => {
sinon.restore();
await db.close();
});

it('does not read data when fields is empty', async () => {
const query = sinon.spy(db._pool, 'query');
const snapshot = await getSnapshot('testcollection', 'test', {}, null);
expect(snapshot).to.eql({id: 'test', v: 1, type: 'json0', data: {}, m: null});
expect(query.lastCall.args[0]).not.to.match(/(?<!AS )\bdata\b/);
});

it('returns no data for a deleted doc when fields is empty', async () => {
const deleted = {type: null, v: 2, data: undefined};
await commit('testcollection', 'test', {v: 1, del: true}, deleted, null);
const snapshot = await getSnapshot('testcollection', 'test', {}, null);
expect(snapshot).to.eql({id: 'test', v: 2, type: null, data: undefined, m: null});
});

it('returns no data for a deleted doc when fields has keys', async () => {
const deleted = {type: null, v: 2, data: undefined};
await commit('testcollection', 'test', {v: 1, del: true}, deleted, null);
const snapshot = await getSnapshot('testcollection', 'test', {x: true}, null);
expect(snapshot.data).to.equal(undefined);
});

it('returns a v0 snapshot for a missing doc when fields is empty', async () => {
const snapshot = await getSnapshot('testcollection', 'missing', {}, null);
expect(snapshot).to.eql({id: 'missing', v: 0, type: null, data: undefined, m: null});
});

it('returns only the requested keys', async () => {
const snapshot = await getSnapshot('testcollection', 'test', {y: true}, null);
expect(snapshot.data).to.eql({y: 6});
});

it('matches keys literally, whatever they contain', async () => {
const keys = [`x'::text[]); DROP TABLE snapshots; --`, 'a,secret', '{secret}', '"secret"', 'NULL'];
const requested = Object.fromEntries(keys.map((key) => [key, true]));
const snapshot = {type: 'json0', v: 1, data: {...requested, secret: true}};
await commit('testcollection', 'hostile', {v: 0, create: {}}, snapshot, null);
const result = await getSnapshot('testcollection', 'hostile', requested, null);
expect(result.data).to.eql(requested);
});

it('returns empty data when no requested key exists', async () => {
const snapshot = await getSnapshot('testcollection', 'test', {z: true}, null);
expect(snapshot.data).to.eql({});
});

it('returns non-object data untouched when fields has keys', async () => {
const snapshot = {type: 'json0', v: 1, data: [1, 2]};
await commit('testcollection', 'array', {v: 0, create: {}}, snapshot, null);
const result = await getSnapshot('testcollection', 'array', {x: true}, null);
expect(result.data).to.eql([1, 2]);
});

it('returns empty data for non-object data when fields is empty', async () => {
const snapshot = {type: 'json0', v: 1, data: [1, 2]};
await commit('testcollection', 'array', {v: 0, create: {}}, snapshot, null);
const result = await getSnapshot('testcollection', 'array', {}, null);
expect(result.data).to.eql({});
});

it('returns all data and metadata when fields has $submit', async () => {
const snapshot = await getSnapshot('testcollection', 'test', {$submit: true, x: true}, null);
expect(snapshot.data).to.eql({x: 5, y: 6});
expect(snapshot.m).to.eql({test: 3});
});
});

describe('through a Backend', function() {
beforeEach(function() {
this.backend = new Backend({db: new PostgresDB({database: DB_NAME})});
});

afterEach(function(done) {
this.backend.close(done);
});

require('sharedb/test/client/projections')({});

it('projects non-object data to null', async function() {
this.backend.addProjection('summary', 'testcollection', {x: true});
const connection = this.backend.connect();
const doc = connection.get('testcollection', 'array');
await promisifyCallback(doc.create.bind(doc))([1, 2]);

const connection2 = this.backend.connect();
const summary = connection2.get('summary', 'array');
await promisifyCallback(summary.fetch.bind(summary))();
expect(summary.data).to.equal(null);
});
});
});
Loading