diff --git a/index.js b/index.js index 0a093b1..b23a7e4 100644 --- a/index.js +++ b/index.js @@ -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 = { @@ -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). // diff --git a/index.spec.js b/index.spec.js index b21888c..f626d44 100644 --- a/index.spec.js +++ b/index.spec.js @@ -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'; @@ -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; @@ -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(/(? { + 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); + }); + }); });