diff --git a/assets/index.css b/assets/index.css index 6dcfdf69..8c870dd3 100644 --- a/assets/index.css +++ b/assets/index.css @@ -3,7 +3,7 @@ Consecutive sizes differ by the cube root of 3 (rounded to the nearest integer.) Font size doubles every 3 steps. */ - @import url('https://fonts.googleapis.com/css?family=IBM+Plex+Mono|IBM+Plex+Sans:400,400i,700&display=swap'); +@import url("https://fonts.googleapis.com/css?family=IBM+Plex+Mono|IBM+Plex+Sans:400,400i,700&display=swap"); body { margin: 0; @@ -28,12 +28,17 @@ th { 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; */ - font-family: 'IBM Plex Sans', monospace; + font-family: "IBM Plex Sans", monospace; color: #333; font-size: 15px; line-height: 1.5em; } -h1, h2, h3, h4, h5, h6 { +h1, +h2, +h3, +h4, +h5, +h6 { font-weight: normal; line-height: 1.26em; padding: 0; @@ -62,7 +67,8 @@ h5 { letter-spacing: 1px; } -blockquote, pre { +blockquote, +pre { padding: 0 16px; margin: 16px 0; border-left: 1px solid #555; @@ -82,7 +88,6 @@ a:hover { text-decoration: none; } - /** Styles for the example **/ .Example { @@ -144,7 +149,7 @@ a:hover { left: -60%; height: 100%; width: 60%; - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; font-size: 16px; line-height: 1.5em; @@ -239,7 +244,7 @@ a:hover { } .Visitor-page::before { - content: ''; + content: ""; position: absolute; border: 2px solid #999; height: 8px; @@ -259,44 +264,44 @@ a:hover { grid-gap: 1em; } - /** * a11y-light theme for JavaScript, CSS, and HTML * Based on the okaidia theme: https://github.com/PrismJS/prism/blob/gh-pages/themes/prism-okaidia.css * @author ericwbailey */ -code, pre { - color: #555; - background: none; - font-family: 'IBM Plex Mono', monospace; +code, +pre { + color: #555; + background: none; + font-family: "IBM Plex Mono", monospace; font-size: 15px; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - line-height: 1.13; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.13; - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; - -webkit-hyphens: none; - -moz-hyphens: none; - -ms-hyphens: none; - hyphens: none; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; } .token.comment, .token.prolog, .token.doctype, .token.cdata { - color: #696969; + color: #696969; } .token.punctuation { - color: #545454; + color: #545454; } .token.property, @@ -304,12 +309,12 @@ code, pre { .token.constant, .token.symbol, .token.deleted { - color: #007faa; + color: #007faa; } .token.boolean, .token.number { - color: #008000; + color: #008000; } .token.selector, @@ -318,7 +323,7 @@ code, pre { .token.char, .token.builtin, .token.inserted { - color: #aa5d00; + color: #aa5d00; } .token.operator, @@ -327,76 +332,76 @@ code, pre { .language-css .token.string, .style .token.string, .token.variable { - color: #008000; + color: #008000; } .token.atrule, .token.attr-value, .token.function { - color: #aa5d00; + color: #aa5d00; } .token.keyword { - color: #d91e18; + color: #d91e18; } .token.regex, .token.important { - color: #d91e18; + color: #d91e18; } .token.important, .token.bold { - font-weight: bold; + font-weight: bold; } .token.italic { - font-style: italic; + font-style: italic; } .token.entity { - cursor: help; -} - -@media screen and (-ms-high-contrast: active) { - code[class*="language-"], - pre[class*="language-"] { - color: windowText; - background: window; - } - - :not(pre) > code[class*="language-"], - pre[class*="language-"] { - background: window; - } - - .token.important { - background: highlight; - color: window; - font-weight: normal; - } - - .token.atrule, - .token.attr-value, - .token.function, - .token.keyword, - .token.operator, - .token.selector { - font-weight: bold; - } - - .token.attr-value, - .token.comment, - .token.doctype, - .token.function, - .token.keyword, - .token.operator, - .token.property, - .token.string { - color: highlight; - } - - .token.attr-value, - .token.url { - font-weight: normal; - } + cursor: help; +} + +@media screen and (prefers-contrast: more) { + code[class*="language-"], + pre[class*="language-"] { + color: windowText; + background: window; + } + + :not(pre) > code[class*="language-"], + pre[class*="language-"] { + background: window; + } + + .token.important { + background: highlight; + color: window; + font-weight: normal; + } + + .token.atrule, + .token.attr-value, + .token.function, + .token.keyword, + .token.operator, + .token.selector { + font-weight: bold; + } + + .token.attr-value, + .token.comment, + .token.doctype, + .token.function, + .token.keyword, + .token.operator, + .token.property, + .token.string { + color: highlight; + } + + .token.attr-value, + .token.url { + font-weight: normal; + } } diff --git a/package-lock.json b/package-lock.json index 5f39d92c..a882cbd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ ], "devDependencies": { "@babel/preset-react": "^7.16.7", - "@biomejs/biome": "^1.7.3", + "@biomejs/biome": "^1.9.4", "@faker-js/faker": "^8.4.1", "@testing-library/react": "^15.0.6", "@types/debug": "^4.1.7", @@ -844,9 +844,9 @@ "license": "MIT" }, "node_modules/@biomejs/biome": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.7.3.tgz", - "integrity": "sha512-ogFQI+fpXftr+tiahA6bIXwZ7CSikygASdqMtH07J2cUzrpjyTMVc9Y97v23c7/tL1xCZhM+W9k4hYIBm7Q6cQ==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", "dev": true, "hasInstallScript": true, "license": "MIT OR Apache-2.0", @@ -861,20 +861,88 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.7.3", - "@biomejs/cli-darwin-x64": "1.7.3", - "@biomejs/cli-linux-arm64": "1.7.3", - "@biomejs/cli-linux-arm64-musl": "1.7.3", - "@biomejs/cli-linux-x64": "1.7.3", - "@biomejs/cli-linux-x64-musl": "1.7.3", - "@biomejs/cli-win32-arm64": "1.7.3", - "@biomejs/cli-win32-x64": "1.7.3" + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.7.3.tgz", - "integrity": "sha512-vnedYcd5p4keT3iD48oSKjOIRPYcjSNNbd8MO1bKo9ajg3GwQXZLAH+0Cvlr+eMsO67/HddWmscSQwTFrC/uPA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", "cpu": [ "x64" ], @@ -889,9 +957,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.7.3.tgz", - "integrity": "sha512-UdEHKtYGWEX3eDmVWvQeT+z05T9/Sdt2+F/7zmMOFQ7boANeX8pcO6EkJPK3wxMudrApsNEKT26rzqK6sZRTRA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", "cpu": [ "x64" ], @@ -905,6 +973,40 @@ "node": ">=14.21.3" } }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/linux-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", diff --git a/package.json b/package.json index 129b6e8c..98694b8d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "homepage": "https://graffy.org", "devDependencies": { "@babel/preset-react": "^7.16.7", - "@biomejs/biome": "^1.7.3", + "@biomejs/biome": "^1.9.4", "@faker-js/faker": "^8.4.1", "@testing-library/react": "^15.0.6", "@types/debug": "^4.1.7", diff --git a/src/pg/sql/clauses.js b/src/pg/sql/clauses.js index 32b200ff..3aee3cd7 100644 --- a/src/pg/sql/clauses.js +++ b/src/pg/sql/clauses.js @@ -1,4 +1,4 @@ -import { isEmpty } from '@graffy/common'; +import { isEmpty, isPlainObject } from '@graffy/common'; import sql, { Sql, empty, join, raw } from 'sql-template-tag'; /* @@ -100,14 +100,40 @@ function castValue(value, type, name, isPut) { if (value === null) return sql`NULL`; if (type === 'jsonb') { - return isPut - ? JSON.stringify(stripAttributes(value)) - : getJsonUpdate(value, name, [])[0]; + return buildJsonPartial(value, isPut); } if (type === 'cube') return cubeLiteralSql(value); - return value; + if (typeof value === 'object' && value.$val) { + return sql`${JSON.stringify(stripAttributes(value))}::jsonb`; + } + + if (typeof value === 'number') return value; + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return sql`${value}`; + if (Array.isArray(value)) return sql`${JSON.stringify(value)}::jsonb`; + + return sql`${JSON.stringify(value)}::jsonb`; +} + +function buildJsonPartial(value, isPut) { + if (!isPlainObject(value)) { + return sql`${JSON.stringify(value)}::jsonb`; + } + + if (Object.keys(value).length === 0 && !isPut) return sql`NULL`; + + const parts = []; + for (const k of Object.keys(value)) { + parts.push(sql`${k}::text, ${buildJsonPartial(value[k], isPut)}`); + } + + if (!parts.length && isPut) return sql`${JSON.stringify(value)}::jsonb`; + + const objSql = sql`jsonb_build_object(${join(parts, ', ')})`; + const filtered = sql`(select jsonb_object_agg(key, value) from jsonb_each(${objSql}) where value <> 'null'::jsonb)`; + return filtered; } export const getInsert = (rows, options) => { @@ -228,17 +254,13 @@ function getJsonUpdate(object, col, path) { function stripAttributes(object) { if (typeof object !== 'object' || !object) return object; - if (Array.isArray(object)) { - return object.map((item) => stripAttributes(item)); + if (Array.isArray(object)) return object.map((item) => stripAttributes(item)); + const res = {}; + for (const k in object) { + if (k === '$put') continue; + const val = stripAttributes(object[k]); + if (val === null) continue; + res[k] = val; } - - return Object.entries(object).reduce( - (/** @type {null|Record} */ out, [key, val]) => { - if (key === '$put' || val === null) return out; - if (out === null) out = {}; - out[key] = stripAttributes(val); - return out; - }, - null, - ); + return Object.keys(res).length ? res : null; } diff --git a/src/pg/test/sql/clauses.test.js b/src/pg/test/sql/clauses.test.js index 6e9c1f45..5e8e2e6f 100644 --- a/src/pg/test/sql/clauses.test.js +++ b/src/pg/test/sql/clauses.test.js @@ -66,4 +66,87 @@ describe('clauses', () => { const query = getSelectCols(options); expectSql(query, sql`*`); }); + + describe('JSONB partial projection SQL', () => { + const options = { + verCol: 'version', + schema: { + types: { + id: 'uuid', + data: 'jsonb', + version: 'int8', + }, + }, + verDefault: 'default', + }; + + test('getUpdates with partial json object', () => { + const row = { + data: { foo: { bar: 33, baz: null }, qux: true }, + version: 10, + $put: true, // Indicates a full put operation + }; + + const res = getUpdates(row, options); + + // Check that it includes jsonb_build_object and filters null. + expect(res.text).toMatch(/jsonb_build_object/); + expect(res.text).toMatch(/jsonb_each/); + expect(res.text).not.toMatch(/"baz"/); // should be filtered out + expect(res.text).toMatch(/"bar"/); // 'bar' should still be present + expect(res.text).toMatch(/"qux"/); + + // Just ensure it compiles as SQL without syntax errors. + expect(res.text).toContain('"data" = '); + expect(res.text).toContain('"version" = default'); + }); + + test('getUpdates with empty object and put', () => { + const row = { + data: {}, + version: 5, + $put: true, + }; + const res = getUpdates(row, options); + // If $put and no fields, we return jsonb '{}' rather than null + // as we are doing a PUT operation. + expect(res.text).toMatch(/jsonb_build_object\(\)/); + }); + + test('getInsert with multiple rows', () => { + const rows = [ + { + id: 'abcd-1234', + data: { + alpha: 1, + nested: { foo: 'bar', removeMe: null }, + arr: [1, 2], + }, + $put: true, + }, + { + id: 'abcd-5678', + data: { onlyNulls: { a: null, b: null } }, + $put: true, + }, + ]; + + const { cols, vals, updates } = getInsert(rows, options); + expect(cols.text).toContain('"id", "data", "version"'); + expect(vals.text).toContain('jsonb_build_object'); + expect(vals.text).not.toContain('removeMe'); // null filtered + expect(vals.text).toContain('arr'); + expect(vals.text).toContain('"onlyNulls"'); // becomes empty object? + expect(updates.text).toContain('"data" = "excluded"."data"'); + }); + + test('no json partial needed', () => { + const row = { + data: { foo: true }, + version: 3, + }; + const res = getUpdates(row, options); + expect(res.text).toContain("jsonb_build_object('foo',"); // no null filtering needed + }); + }); });