json-parser.html 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. Manually parsing JSON-formatted data in SQL can be tedious. 🫠 You can use the
  2. widget below to <b>automatically</b> turn a sample JSON payload into a parsing
  3. view with the individual fields mapped to columns.
  4. <div class="json_widget">
  5. <div class="json">
  6. <textarea title="JSON sample" id="json_sample" placeholder="JSON sample">
  7. { "payload": "materialize", "event": { "kind": 1, "success": true, "createdAt": "2023-02-01T17:00:00.000Z" }, "ts": "2023-02-01T17:00:00.000Z" }
  8. </textarea>
  9. <div id="error_span" class="error">
  10. <p id="error_text"></p>
  11. </div>
  12. </div>
  13. <span class="input_container">
  14. <span class="input_container-text">
  15. <input title="View Name" id="view_name" placeholder="View Name" value="my_view">
  16. <input title="Relation Name" id="source_name" placeholder="Relation Name" value="my_source">
  17. <input title="JSON Column Name" id="column_name" placeholder="JSON Column Name" value="json_column">
  18. </span>
  19. <fieldset title="Target object type" class="input_container-radio">
  20. <legend>Target object type</legend>
  21. <span>
  22. <input type="radio" id="view" name="type_view" value="view" checked/>
  23. <label for="view">View</label>
  24. </span>
  25. <span>
  26. <input type="radio" id="materialized-view" name="type_view" value="materialized-view"/>
  27. <label for="materialized-view">Materialized view</label>
  28. </span>
  29. </fieldset>
  30. </span>
  31. <pre title="Generated SQL" class="sql_output chroma"><code id="output" class="sql_output-code language-sql" data-lang="sql"></code></pre>
  32. </div>
  33. <script>
  34. /* Helper Methods */
  35. function escapeString(s) {
  36. return s.replace(/'/g, `''`);
  37. }
  38. function escapeIdent(s) {
  39. return `"${s.replace(/"/g, `""`)}"`;
  40. }
  41. function replaceSpecialChar(s) {
  42. return s.replace(/[^a-zA-Z0-9 _]/g, "_")
  43. }
  44. function isDate(value) {
  45. var dateParsed = new Date(Date.parse(value));
  46. if (isNaN(dateParsed.getTime())) {
  47. return false;
  48. }
  49. var isoString = dateParsed.toISOString();
  50. return isoString === value;
  51. }
  52. /* JSON Parsing and SQL conversion */
  53. const errorSpan = document.getElementById("error_span");
  54. const errorText = document.getElementById("error_text");
  55. const jsonInput = document.getElementById("json_sample");
  56. const sqlOutput = document.getElementById("output");
  57. /// Flattens a JSON objects into a list of fields, and their chain of parents.
  58. function handleJson(source, sample, columnName) {
  59. if (!columnName) {
  60. columnName = "body"
  61. }
  62. let selectItems = [];
  63. const jsonObject = JSON.parse(sample);
  64. // Format the JSON for the user.
  65. const prettyJson = JSON.stringify(jsonObject, null, 2);
  66. jsonInput.value = prettyJson;
  67. expandObject(jsonObject, [columnName], selectItems);
  68. return selectItems;
  69. }
  70. /// Recursively iterates through the provided object, tracking the chain
  71. /// of parent fields for later use in naming and desctructuring.
  72. function expandObject(object, parents, columns) {
  73. if (Array.isArray(object)) {
  74. handleArray(object, parents, columns);
  75. } else if (isObject(object)) {
  76. handleObject(object, parents, columns);
  77. } else {
  78. handlePrimitive(object, parents, columns);
  79. }
  80. }
  81. /// Handles arrays within the JSON structure, including empty arrays.
  82. function handleArray(array, parents, columns) {
  83. if (array.length === 0) {
  84. // Specifically handles empty arrays to add placeholder values rather than skipping them.
  85. handleEmptyArray(parents, columns);
  86. } else {
  87. // For non-empty arrays, iterate over each item, expanding it.
  88. array.forEach((item, index) => {
  89. const newParents = parents.concat(index.toString());
  90. expandObject(item, newParents, columns);
  91. });
  92. }
  93. }
  94. /// Handles objects by iterating over each property and recursively expanding it.
  95. function handleObject(obj, parents, columns) {
  96. Object.entries(obj).forEach(([key, value]) => {
  97. const newParents = parents.concat(key);
  98. expandObject(value, newParents, columns);
  99. });
  100. }
  101. // Handles primitive values by determining their SQL data type and adding them to the columns list.
  102. function handlePrimitive(value, parents, columns) {
  103. // Determine the appropriate SQL cast and function wrapper for the value.
  104. const { cast, wrapping_function, isNull } = determineCast(value);
  105. // Uses the last part of the path as the column name.
  106. const columnName = parents.slice(-1)[0];
  107. // Generate an alias for the column based on its path, excluding the root.
  108. const columnAlias = parents.slice(1).join("_");
  109. // Add the column definition to the list of columns.
  110. columns.push([columnName, wrapping_function, cast, parents, isNull, value]);
  111. }
  112. /// Handles the specific case of an empty array by adding a placeholder column definition.
  113. function handleEmptyArray(parents, columns) {
  114. // Generates a column name and alias based on the path to the empty array.
  115. const columnName = parents.join("_");
  116. const columnAlias = parents.slice(1).join("_");
  117. // Adds a placeholder column for the empty array.
  118. columns.push([columnAlias, "", "text", parents, true, "''"]);
  119. }
  120. /// Utility function to check if a given value is an object.
  121. function isObject(value) {
  122. // Returns true if the value is an object and not null.
  123. return typeof value === 'object' && value !== null;
  124. }
  125. function formSql(selectItems, viewName, sourceName, objectType, columnName) {
  126. if (!viewName) {
  127. viewName = "my_view";
  128. }
  129. if (!sourceName) {
  130. sourceName = "my_source";
  131. }
  132. let type = "VIEW";
  133. if (objectType === "materialized-view") {
  134. type = "MATERIALIZED VIEW";
  135. }
  136. // Map all of our field into a column reference and alias.
  137. const rawSqlParts = selectItems.map(([name, wrapping_function, cast, parents, isNull, value]) => {
  138. let path = columnName || "body";
  139. // Iterate over parents to build the path, skipping the columnName/base if it's included
  140. parents.slice(columnName ? 1 : 0, parents.length - 1).forEach(parent => {
  141. path += isNaN(parent) ? `->'${parent}'` : `->${parent}`;
  142. });
  143. // Determine the final part of the path
  144. let finalPart = path + (isNaN(name) ? `->>'${name}'` : `->${name}`);
  145. // Wrap the final part in parentheses if casting is applied
  146. let sqlItem = cast ? `(${finalPart})::${cast}` : finalPart;
  147. if (wrapping_function) {
  148. sqlItem = `${wrapping_function}(${sqlItem})`;
  149. }
  150. // Construct column alias without redundant name appending
  151. let columnAliasParts = parents.slice(columnName ? 1 : 0);
  152. let columnAlias = replaceSpecialChar(columnAliasParts.join("_"));
  153. return [sqlItem, columnAlias];
  154. });
  155. // De-dupe column aliases by optionally appending a suffix.
  156. const nextSuffixes = new Map();
  157. const sqlParts = rawSqlParts.map(([sqlItem, columnAlias]) => {
  158. const suffix = nextSuffixes.get(columnAlias);
  159. // If we haven't seen this column alias before, no need to add a suffix.
  160. if (suffix === undefined) {
  161. nextSuffixes.set(columnAlias, 1);
  162. } else {
  163. nextSuffixes.set(columnAlias, suffix + 1);
  164. columnAlias = `${columnAlias}${suffix}`;
  165. }
  166. return `${sqlItem} AS ${columnAlias}`;
  167. })
  168. .join(",\n ");
  169. return `CREATE ${type} ${viewName} AS SELECT\n ${sqlParts}\nFROM ${sourceName};`;
  170. }
  171. /// Helper to determine cast and wrapping function based on value type
  172. function determineCast(value) {
  173. let cast = "";
  174. let wrapping_function = "";
  175. let isNull = value === null;
  176. if (!isNull) {
  177. if (typeof value === "boolean") {
  178. cast = "bool";
  179. } else if (typeof value === "number") {
  180. cast = "numeric";
  181. } else if (typeof value === "string") {
  182. if (isDate(value)) {
  183. // If the string is a valid ISO8601 date, cast it to a timestamp
  184. // TODO: This could be improved to handle other date formats
  185. wrapping_function = "try_parse_monotonic_iso8601_timestamp";
  186. }
  187. }
  188. }
  189. return { cast, wrapping_function, isNull };
  190. }
  191. function errorClear() {
  192. errorSpan.className = 'error error-hidden';
  193. }
  194. function errorSet(e) {
  195. errorText.textContent = e.message;
  196. errorSpan.className = 'error error-visible';
  197. }
  198. function sqlSet(sql) {
  199. sqlOutput.textContent = sql.trim();
  200. }
  201. function sqlClear() {
  202. sqlOutput.textContent = "";
  203. }
  204. function render() {
  205. errorClear();
  206. sqlClear();
  207. const viewName = document.getElementById("view_name").value;
  208. const sourceName = document.getElementById("source_name").value;
  209. const columnName = document.getElementById("column_name").value;
  210. const objectType = document.querySelector("input[name='type_view']:checked").value;
  211. const jsonSample = jsonInput.value;
  212. try {
  213. const items = handleJson(sourceName, jsonSample, columnName);
  214. const sql = formSql(items, viewName, sourceName, objectType, columnName);
  215. sqlSet(sql);
  216. errorClear();
  217. } catch (e) {
  218. if (jsonSample) {
  219. console.error(e);
  220. errorSet(e);
  221. } else {
  222. errorClear();
  223. }
  224. }
  225. }
  226. render();
  227. // Event listeners for input and change events
  228. document.getElementById("view_name").addEventListener("input", render);
  229. document.getElementById("source_name").addEventListener("input", render);
  230. document.getElementById("column_name").addEventListener("input", render);
  231. document.querySelectorAll("input[name='type_view']").forEach(element => {
  232. element.addEventListener("change", render);
  233. });
  234. let debounceTimer;
  235. document.getElementById("json_sample").addEventListener("input", function(event) {
  236. clearTimeout(debounceTimer);
  237. debounceTimer = setTimeout(() => {
  238. render();
  239. }, 600);
  240. });
  241. </script>