├── .github └── workflows │ └── next.yaml ├── .gitignore ├── .prettierrc ├── README.md ├── components ├── corner.js ├── footer.js ├── github.js ├── header.js ├── layout.js ├── linkedin.js └── twitter.js ├── context ├── courseInfoContext.js └── headerContext.js ├── course.json ├── csv └── index.js ├── data ├── course.js └── lesson.js ├── lessons ├── 01-welcome │ ├── A-intro.md │ └── B-docker.md ├── 02-databases-and-tables │ ├── A-databases.md │ ├── B-tables.md │ └── meta.json ├── 03-data │ ├── A-inserts.md │ ├── B-updates-and-deletes.md │ ├── C-selects.md │ ├── D-like-and-functions.md │ ├── E-nodejs-and-postgresql.md │ ├── F-project.md │ └── meta.json ├── 04-joins-and-constraints │ ├── A-relationships.md │ ├── B-other-types-of-joins.md │ ├── C-foreign-keys.md │ ├── D-many-to-many.md │ ├── E-constraints.md │ ├── F-project.md │ └── meta.json ├── 05-jsonb │ ├── A-unstructured-data.md │ ├── B-jsonb.md │ ├── C-when-to-use.md │ └── meta.json ├── 06-aggregation │ ├── A-aggregation.md │ ├── B-having.md │ └── meta.json ├── 07-functions-triggers-and-procedures │ ├── A-functions.md │ ├── B-procedures.md │ ├── C-triggers.md │ └── meta.json ├── 08-the-movie-database │ ├── A-exercises.md │ ├── B-answers.md │ ├── C-pgadmin.md │ └── meta.json ├── 09-query-performance │ ├── A-explain.md │ ├── B-indexes.md │ ├── C-create-an-index.md │ ├── D-gin.md │ ├── E-partial-indexes.md │ ├── F-derivative-value-indexes.md │ └── meta.json ├── 10-views │ ├── A-basic-views.md │ ├── B-materialized-views.md │ └── meta.json ├── 11-subqueries │ ├── A-how-to-subquery.md │ ├── B-arrays.md │ └── meta.json ├── 12-transactions │ ├── A-transactions.md │ └── meta.json ├── 13-window-functions │ ├── A-window-functions.md │ └── meta.json ├── 14-self-join │ ├── A-self-join.md │ └── meta.json └── 15-conclusion │ ├── A-congrats.md │ ├── B-project.md │ └── meta.json ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── index.js └── lessons │ └── [section] │ └── [slug].js ├── public ├── .nojekyll ├── ReelingIt.zip ├── images │ ├── Landing.png │ ├── SQL_Joins.png │ ├── add-new-server.png │ ├── apple-touch-icon.png │ ├── author.jpg │ ├── config.png │ ├── connection.png │ ├── course-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── social-share-cover.jpg └── recipes.sql └── styles ├── courses.css ├── footer.css └── variables.css /.github/workflows/next.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy NextJS Course Site to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@master 13 | - name: npm install, export 14 | run: | 15 | npm install 16 | npm run export 17 | - name: Deploy site to gh-pages branch 18 | uses: crazy-max/ghaction-github-pages@v2 19 | with: 20 | target_branch: gh-pages 21 | build_dir: out 22 | fqdn: sql.holt.courses 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | *.csv 33 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

code logo

2 | 3 | [![Frontend Masters](https://static.frontendmasters.com/assets/brand/logos/full.png)][fem] 4 | 5 |

6 | Learn to construct databases, write queries, and optimize SQL with industry veteran Brian Holt. 7 |

8 | 9 | ## License 10 | 11 | The **code** in this repo is licensed under the Apache 2.0 license. 12 | 13 | The **content** is licensed under CC-BY-NC-4.0. 14 | 15 | [fem]: https://frontendmasters.com/workshops/complete-intro-sql/ 16 | 17 | ## Course Icon License 18 | 19 | SQL icons created by juicy_fish - Flaticon 20 | -------------------------------------------------------------------------------- /components/corner.js: -------------------------------------------------------------------------------- 1 | export default function Corner() { 2 | return ( 3 |
4 | 11 | 12 | 13 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 35 | 42 | 43 | 44 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/footer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Gh from "./github"; 3 | import Tw from "./twitter"; 4 | import Li from "./linkedin"; 5 | 6 | export default function Footer({ twitter, linkedin, github }) { 7 | return ( 8 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /components/github.js: -------------------------------------------------------------------------------- 1 | export default function GitHub() { 2 | return ( 3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 21 | 28 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/header.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import Link from "next/link"; 3 | import { Context as HeaderContext } from "../context/headerContext"; 4 | import { Context as CourseContext } from "../context/courseInfoContext"; 5 | 6 | export default function Header(props) { 7 | const [{ section, title, icon }] = useContext(HeaderContext); 8 | const { frontendMastersLink } = useContext(CourseContext); 9 | return ( 10 |
11 |

12 | {props.title} 13 |

14 |
15 | {frontendMastersLink ? ( 16 | 17 | Watch on Frontend Masters 18 | 19 | ) : null} 20 | {section ? ( 21 |

22 | {section} {title} 23 |

24 | ) : null} 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/layout.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import Footer from "./footer"; 4 | import Header from "./header"; 5 | import getCourseConfig from "../data/course"; 6 | import { Provider as HeaderProvider } from "../context/headerContext"; 7 | import { Provider as CourseInfoProvider } from "../context/courseInfoContext"; 8 | 9 | function Layout({ children }) { 10 | const courseInfo = getCourseConfig(); 11 | const headerHook = useState({}); 12 | return ( 13 | 14 | 15 |
16 |
17 |
18 |
{children}
19 |
20 |
25 |
26 | 27 | 34 |
35 |
36 | ); 37 | } 38 | 39 | export default function App({ children }) { 40 | return {children}; 41 | } 42 | -------------------------------------------------------------------------------- /components/linkedin.js: -------------------------------------------------------------------------------- 1 | export default function LinkedIn() { 2 | return ( 3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 21 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/twitter.js: -------------------------------------------------------------------------------- 1 | export default function Twitter() { 2 | return ( 3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 21 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /context/courseInfoContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | const courseInfoContext = createContext([{}, () => {}]); 4 | 5 | export const Provider = courseInfoContext.Provider; 6 | export const Consumer = courseInfoContext.Consumer; 7 | export const Context = courseInfoContext; 8 | -------------------------------------------------------------------------------- /context/headerContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | const headerContext = createContext([{}, () => {}]); 4 | 5 | export const Provider = headerContext.Provider; 6 | export const Consumer = headerContext.Consumer; 7 | export const Context = headerContext; 8 | -------------------------------------------------------------------------------- /course.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "name": "Brian Holt", 4 | "company": "Stripe" 5 | }, 6 | "title": "Complete Intro to SQL", 7 | "subtitle": "and PostgreSQL", 8 | "frontendMastersLink": "https://holt.fyi/sql", 9 | "social": { 10 | "linkedin": "btholt", 11 | "github": "btholt", 12 | "twitter": "holtbt" 13 | }, 14 | "description": "Come learn the basics to querying SQL and managing PostgreSQL with industry expert Brian Holt", 15 | "keywords": ["sql", "querying", "data", "Brian Holt", "node.js", "javascript"], 16 | "productionBaseUrl": "", 17 | "csvPath": "./out/lessons.csv" 18 | } 19 | -------------------------------------------------------------------------------- /csv/index.js: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import path from "path"; 3 | import { convertArrayToCSV } from "convert-array-to-csv"; 4 | import { getLessons } from "../data/lesson.js"; 5 | 6 | async function start() { 7 | const configBuffer = await fs.readFile( 8 | path.join(process.cwd(), "course.json") 9 | ); 10 | const config = JSON.parse(configBuffer); 11 | 12 | if (!config.csvPath) { 13 | console.log("no csvPath in course.json, skipping CSV generation"); 14 | return; 15 | } 16 | 17 | process.env.BASE_URL = config?.productionBaseUrl || ""; 18 | const sections = await getLessons(); 19 | 20 | const lessons = []; 21 | 22 | for (let i = 0; i < sections.length; i++) { 23 | const section = sections[i]; 24 | 25 | for (let j = 0; j < section.lessons.length; j++) { 26 | const lesson = section.lessons[j]; 27 | 28 | lessons.push({ 29 | order: lesson.order, 30 | sectionTitle: section.title, 31 | lessonTitle: lesson.title, 32 | slug: section.slug + "/" + lesson.slug, 33 | sectionIcon: section.icon, 34 | filePath: lesson.fullSlug, 35 | description: lesson.description, 36 | }); 37 | } 38 | } 39 | 40 | const csv = convertArrayToCSV(lessons); 41 | 42 | await fs.writeFile(config.csvPath, csv); 43 | console.log(`wrote ${lessons.length} rows to ${config.csvPath}`); 44 | } 45 | 46 | start(); 47 | -------------------------------------------------------------------------------- /data/course.js: -------------------------------------------------------------------------------- 1 | import config from "../course.json"; 2 | 3 | const DEFAULT_CONFIG = { 4 | author: { 5 | name: "An Author", 6 | company: "An Author's Company", 7 | }, 8 | title: "A Superb Course", 9 | subtitle: "That Teaches Nice Things", 10 | frontendMastersLink: "", 11 | description: "A nice course for nice people.", 12 | keywords: ["a nice course", "for people", "to learn", "nice things"], 13 | social: { 14 | linkedin: "btholt", 15 | github: "btholt", 16 | twitter: "holtbt", 17 | }, 18 | productionBaseUrl: "/", 19 | }; 20 | 21 | export default function getCourseConfig() { 22 | return Object.assign({}, DEFAULT_CONFIG, config); 23 | } 24 | -------------------------------------------------------------------------------- /data/lesson.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs/promises"; 3 | import matter from "gray-matter"; 4 | import { titleCase } from "title-case"; 5 | import { marked } from "marked"; 6 | import hljs from "highlight.js"; 7 | 8 | const DEFAULT_ICON = "info-circle"; 9 | const lessonsPath = path.join(process.cwd(), "lessons"); 10 | 11 | function getTitle(slug, override) { 12 | let title = override; 13 | if (!title) { 14 | title = titleCase(slug.split("-").join(" ")); 15 | } 16 | 17 | return title; 18 | } 19 | 20 | async function getMeta(section) { 21 | let meta = {}; 22 | try { 23 | const file = await fs.readFile( 24 | path.join(lessonsPath, section, "meta.json") 25 | ); 26 | meta = JSON.parse(file.toString()); 27 | } catch (e) { 28 | // no meta.json, nothing to do 29 | } 30 | 31 | return meta; 32 | } 33 | 34 | function slugify(inputPath) { 35 | const pathParts = inputPath.split("-"); 36 | const pathOrder = pathParts.shift(); 37 | const pathSlug = pathParts.join("-"); 38 | return { 39 | slug: pathSlug, 40 | order: pathOrder, 41 | title: titleCase(pathParts.join(" ")), 42 | }; 43 | } 44 | 45 | export async function getLessons() { 46 | marked.setOptions({ 47 | baseUrl: process.env.BASE_URL ? process.env.BASE_URL + "/" : "/", 48 | highlight: function (code, lang) { 49 | const language = hljs.getLanguage(lang) ? lang : "plaintext"; 50 | return hljs.highlight(code, { language }).value; 51 | }, 52 | langPrefix: "hljs language-", 53 | }); 54 | 55 | const dir = await fs.readdir(lessonsPath); 56 | const sections = []; 57 | 58 | for (let dirFilename of dir) { 59 | const dirStats = await fs.lstat(path.join(lessonsPath, dirFilename)); 60 | 61 | if (dirStats.isFile()) { 62 | continue; 63 | } 64 | 65 | const lessonsDir = await fs.readdir(path.join(lessonsPath, dirFilename)); 66 | 67 | let { 68 | title: sectionTitle, 69 | order: sectionOrder, 70 | slug: sectionSlug, 71 | } = slugify(dirFilename); 72 | 73 | let icon = DEFAULT_ICON; 74 | 75 | const meta = await getMeta(dirFilename); 76 | if (meta.title) { 77 | sectionTitle = meta.title; 78 | } 79 | if (meta.icon) { 80 | icon = meta.icon; 81 | } 82 | 83 | const lessons = []; 84 | for (let lessonFilename of lessonsDir) { 85 | if (lessonFilename.slice(-3) !== ".md") { 86 | continue; 87 | } 88 | 89 | const filePath = path.join(lessonsPath, dirFilename, lessonFilename); 90 | 91 | const file = await fs.readFile(filePath); 92 | const { data } = matter(file.toString()); 93 | let slug = lessonFilename.replace(/\.md$/, ""); 94 | 95 | const slugParts = slug.split("-"); 96 | const lessonOrder = slugParts.shift(); 97 | 98 | slug = slugParts.join("-"); 99 | 100 | const title = getTitle(slug, data.title); 101 | 102 | lessons.push({ 103 | slug, 104 | fullSlug: `/lessons/${sectionSlug}/${slug}`, 105 | title, 106 | order: `${sectionOrder}${lessonOrder.toUpperCase()}`, 107 | path: filePath, 108 | description: data.description ? data.description : "", 109 | }); 110 | } 111 | 112 | sections.push({ 113 | icon, 114 | title: sectionTitle, 115 | slug: sectionSlug, 116 | lessons, 117 | order: sectionOrder, 118 | }); 119 | } 120 | 121 | return sections; 122 | } 123 | 124 | export async function getLesson(targetDir, targetFile) { 125 | const dir = await fs.readdir(lessonsPath); 126 | 127 | for (let i = 0; i < dir.length; i++) { 128 | const dirPath = dir[i]; 129 | if (dirPath.endsWith(targetDir)) { 130 | const lessonDir = ( 131 | await fs.readdir(path.join(lessonsPath, dirPath)) 132 | ).filter((str) => str.endsWith(".md")); 133 | 134 | for (let j = 0; j < lessonDir.length; j++) { 135 | const slugPath = lessonDir[j]; 136 | if (slugPath.endsWith(targetFile + ".md")) { 137 | const filePath = path.join(lessonsPath, dirPath, slugPath); 138 | const file = await fs.readFile(filePath); 139 | const { data, content } = matter(file.toString()); 140 | const html = marked(content); 141 | const title = getTitle(targetFile, data.title); 142 | const meta = await getMeta(dirPath); 143 | 144 | const section = getTitle(targetDir, meta.title); 145 | const icon = meta.icon ? meta.icon : DEFAULT_ICON; 146 | 147 | let nextSlug; 148 | let prevSlug; 149 | 150 | // get next 151 | if (lessonDir[j + 1]) { 152 | // has next in section 153 | const { slug: next } = slugify(lessonDir[j + 1]); 154 | nextSlug = `${targetDir}/${next.replace(/\.md$/, "")}`; 155 | } else if (dir[i + 1]) { 156 | // has next in next section 157 | const nextDir = ( 158 | await fs.readdir(path.join(lessonsPath, dir[i + 1])) 159 | ).filter((str) => str.endsWith(".md")); 160 | const nextDirSlug = slugify(dir[i + 1]).slug; 161 | const nextLessonSlug = slugify(nextDir[0]).slug.replace( 162 | /\.md$/, 163 | "" 164 | ); 165 | nextSlug = `${nextDirSlug}/${nextLessonSlug}`; 166 | } else { 167 | // last section 168 | nextSlug = null; 169 | } 170 | 171 | // get prev 172 | if (lessonDir[j - 1]) { 173 | // has prev in section 174 | const { slug: prev } = slugify(lessonDir[j - 1]); 175 | prevSlug = `${targetDir}/${prev.replace(/\.md$/, "")}`; 176 | } else if (dir[i - 1]) { 177 | // has prev in prev section 178 | const prevDir = ( 179 | await fs.readdir(path.join(lessonsPath, dir[i - 1])) 180 | ).filter((str) => str.endsWith(".md")); 181 | const prevDirSlug = slugify(dir[i - 1]).slug; 182 | const prevLessonSlug = slugify( 183 | prevDir[prevDir.length - 1] 184 | ).slug.replace(/\.md$/, ""); 185 | prevSlug = `${prevDirSlug}/${prevLessonSlug}`; 186 | } else { 187 | // first section 188 | prevSlug = null; 189 | } 190 | 191 | const base = process.env.BASE_URL ? process.env.BASE_URL : "/"; 192 | 193 | return { 194 | attributes: data, 195 | html, 196 | slug: targetFile, 197 | title, 198 | section, 199 | icon, 200 | filePath, 201 | nextSlug: nextSlug ? path.join(base, "lessons", nextSlug) : null, 202 | prevSlug: prevSlug ? path.join(base, "lessons", prevSlug) : null, 203 | }; 204 | } 205 | } 206 | } 207 | } 208 | 209 | return false; 210 | } 211 | -------------------------------------------------------------------------------- /lessons/01-welcome/A-intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Introduction" 3 | description: "Brian welcomes you to the course and sets expectations of what you will learn in the Complete Intro to SQL and PostgreSQL" 4 | --- 5 | 6 | Hello! And welcome to the Complete Intro to SQL and PostgreSQL as taught by [Brian Holt][twitter]. 7 | 8 | ![course logo](./images/course-icon.png) 9 | 10 | I wrote this course to help developers get more acquainted with what can be an intimidating topic: SQL. SQL, like coding in general, has a great amount of depth to it which can make it seem unapproachable to people who haven't spent much time with it. I can tell you as a veteran developer with lots of experience with SQL that while there is much depth to be had, there is a lot you can get done with an introductory set of knowledge. A cursory knowledge of SQL yields most of the benefit in my opinion and the depth only makes it better. 11 | 12 | In this course you will learn 13 | 14 | - How to set up your first database and tables 15 | - How to think about data going into your tables 16 | - The best ways to query your data for your application 17 | - How to identify and optimize slow queries 18 | - How to connect your SQL queries to Node.js 19 | 20 | It does bear talking about that we are _not_ going to be talking about managing and operating database e.g. how to deploy it, how to containerize it, how to replicate and shard data, etc. This is a developer intro and solely focused on the developer's perspective. 21 | 22 | ## Who is this course for / Prerequisites 23 | 24 | You, hopefully! 25 | 26 | The course is designed for developers who want to know better how to use SQL and PostgreSQL. Here's what I'd suggest you know before starting this course: 27 | 28 | - [Internet Fundamentals][internet-fundamentals] – This is essential. You should have the fundamentals of using a computer and the Internet down. 29 | - [Basic coding abilities, preferably JavaScript][web-dev-v3] – A little coding experience at the very least will help a lot with the logical reasoning in this class. 30 | - [A little Node.js][njs] – Less essential but will help a lot with the exercises. Outside of the two exercises there are no Node.js sections. 31 | 32 | ## Set up 33 | 34 | This course works and has been tested on both macOS and Windows 10/11. It also will work very well on Linux (just follow the macOS instructions). You shouldn't need a particularly powerful computer for any part of this course. 8GB of RAM would more than get you through it and you can likely get away with less. 35 | 36 | - You will need to install Docker. Instructions are in the next lesson. 37 | - While you do not have to use [Visual Studio Code][vsc], it is what I will be using and I'll be giving you fun tips for it along the way. I was on the VS Code team so I'm a bit biased! 38 | - People often ask me what my coding set up is so let's go over that really quick! 39 | - Font: [MonoLisa][monolisa]. Be sure to [enable ligatures][ligatures] in VS Code! If you want ligatures without Dank, check out Microsoft's [Cascadia Code][cascadia]. 40 | - Theme: I actually just like Dark+, the default VS Code theme. Though I do love [Sarah Drasner's Night Owl][night-owl] too. 41 | - Terminal: I just switched back to using macOS's built in terminal. [iTerm2][iterm] is great too. On Windows I love [Windows Terminal][terminal]. 42 | - VS Code Icons: the [vscode-icons][icons] extension. 43 | 44 | ## Where to File Issues 45 | 46 | I write these courses and take care to not make mistakes. However when teaching many hours of material, mistakes are inevitable, both here in the grammar and in the course with the material. However I (and the wonderful team at Frontend Masters) are constantly correcting the mistakes so that those of you that come later get the best product possible. If you find a mistake we'd love to fix it. The best way to do this is to [open a pull request or file an issue on the GitHub repo][issues]. While I'm always happy to chat and give advice on social media, I can't be tech support for everyone. And if you file it on GitHub, those who come later can Google the same answer you got. 47 | 48 | ## Who am I 49 | 50 | My name is Brian Holt and I am a product manager at Stripe. I work on all sorts of developer tools like the Stripe VS Code extension, the Stripe CLI, the Stripe SDKs, and other tools developers use to write code for Stripe. Before that I worked on Azure and VS Code at Microsoft as a PM and before that I was JavaScript (both frontend and Node.js) developer for a decade at companies like LinkedIn, Netflix, Reddit, and some other startups. I've written _a lot_ of code and written a lot of queries. 51 | 52 | I learned to code when I was somewhere around 10 years old. My brother used to make me write C++ before he'd let me play video games. This definitely set me up for success when I started to learn to code in college but let me tell you: it is not too late for anyone to code. The skills here are very learnable for anyone with enough gumption to get going. I started writing database queries against MySQL at my internship. 53 | 54 | When I'm not working or developing new Frontend Masters courses, you'll find me in Seattle, WA. I love to travel, hang out with my wife and son, get out of breath on my Peloton, play Dota 2 and Overwatch poorly, as well as drink Islay Scotches, local IPAs and fruity coffees. 55 | 56 | ![Brian teaching](./images/social-share-cover.jpg) 57 | 58 | Catch up with me on social media! I'll be honest: I'm not great at responding at DMs. The best way to talk to me is just to tweet at me. 59 | 60 | - [Twitter][twitter] 61 | - [LinkedIn][linkedin] 62 | - [GitHub][github] 63 | - [Peloton][pelo] (you have to be a member and signed in for this link to work) 64 | 65 | And one last request! [Please star this repo][site]. It helps the course be more discoverable and with my fragile ego. 66 | 67 | [twitter]: https://twitter.com/holtbt 68 | [vsc]: https://code.visualstudio.com/ 69 | [monolisa]: https://www.monolisa.dev/ 70 | [ligatures]: https://worldofzero.com/posts/enable-font-ligatures-vscode/ 71 | [night-owl]: https://marketplace.visualstudio.com/items?itemName=sdras.night-owl 72 | [cascadia]: https://github.com/microsoft/cascadia-code 73 | [terminal]: https://www.microsoft.com/en-us/p/windows-terminal/9n0dx20hk701?activetab=pivot:overviewtab 74 | [icons]: https://marketplace.visualstudio.com/items?itemName=vscode-icons-team.vscode-icons 75 | [iterm]: https://iterm2.com/ 76 | [issues]: https://github.com/btholt/complete-intro-to-sql/issues 77 | [github]: https://github.com/btholt 78 | [linkedin]: https://www.linkedin.com/in/btholt/ 79 | [gh]: https://btholt.github.io/complete-intro-to-sql 80 | [site]: https://github.com/btholt/complete-intro-to-sql 81 | [tweet]: https://twitter.com/holtbt/status/493852312604254208 82 | [pelo]: https://members.onepeloton.com/members/btholt/overview 83 | [internet-fundamentals]: https://internetfundamentals.com/ 84 | [fem]: https://www.frontendmasters.com 85 | [twitter]: https://twitter.com/holtbt 86 | [fem]: https://www.frontendmasters.com 87 | [web-dev-v3]: https://frontendmasters.com/courses/web-development-v3/ 88 | [njs]: https://frontendmasters.com/courses/node-js-v2/ 89 | -------------------------------------------------------------------------------- /lessons/01-welcome/B-docker.md: -------------------------------------------------------------------------------- 1 | This is not intended to be a Docker course. If you do want a complete course on containers on Docker, [please watch my other course on Frontend Masters][fem]. 2 | 3 | However, the _fastest_ way we can get everyone up and running on PostgreSQL is to have everyone use Docker containers. Dockers allows any computer to run a lightweight emulation of a pre-made environment (typically Linux). In our case we're going to be using a premade Linux environment that has PostgreSQL already installed on it. 4 | 5 | ## Docker Desktop 6 | 7 | [Please go install Docker Desktop][docker]. Docker Desktop is a product that gets Docker all set up for you without much effort regardless what OS you're using. 8 | 9 | You are welcome to use Docker without Docker Desktop but you're on your own that point. 10 | 11 | ## The Command Line 12 | 13 | You will need to interact a bit with the command line, either via Linux/macOS's zsh/bash or via Window's PowerShell. If you need help with CLIs or Linux, [click here to see my course on Frontend Masters][linux]. 14 | 15 | The amount of CLI usage should be fairly minimal and you should be able to just copy/paste exactly what I give you. 16 | 17 | ## The Two Containers You'll Need 18 | 19 | We're going to use two containers: 20 | 21 | - [PostgreSQL 14][pg] 22 | - [btholt/complete-intro-to-sql][btholt] 23 | 24 | The first is the base, official image of PostgreSQL 14 (the latest stable version as of writing.) I'll be using 14.3 but you can likely use anything that's 14.X. Normally they don't break things between version. If there's a new major stable version (e.g. 15.X, 16.X, etc.) I would not recommend taking the class using those. Things can break between major versions. Use 14.X for this course and then go see what's different after. 25 | 26 | The latter container is based on the same Postgres 14 container but preload it with a bunch of movie data from the [Open Movie Database][omdb] as well as the complete RecipeGuru database. This is a dump of that database for us to play around with. This is based on [this setup script by credativ][credativ]. If you ever break anything, just shut down your container and restart it. 27 | 28 | ## Get Running 29 | 30 | > Do note, these containers are quite large. Each is ~0.5GB. 31 | 32 | Run the following to get yourself prepped for the course (optionally, you can let Docker do it for you under the hood too) 33 | 34 | ```bash 35 | docker pull postgres:14 36 | docker pull btholt/complete-intro-to-sql 37 | ``` 38 | 39 | To make sure you're working, run the following: 40 | 41 | ```bash 42 | docker run -e POSTGRES_PASSWORD=lol --name=pg --rm -d -p 5432:5432 postgres:14 43 | ``` 44 | 45 | This should run PostgreSQL in the background. 46 | 47 | - We gave it a password of "lol" (feel free to change it to something different, I just remember lol because lol) 48 | - We ran it with a name of `pg` so we can refer it with that shorthand 49 | - We used the `--rm` flag so the container deletes itself afterwards (rather than leave its logs and such around) 50 | - We ran it in the background with `-d`. Otherwise it'll start in the foreground. 51 | - The `-p` allows us to expose port 5432 locally which is the port Postgres runs on by default. 52 | 53 | Run `docker ps` to see it running. You can also see it in the Docker Desktop app running under the containers tab. 54 | 55 | Now run `docker kill pg` to kill the container. This will shut down the container and since we ran it with `--rm` it'll clean itself up afterwards too. We can run the `docker run …` to start it again. 56 | 57 | Okay, let's try connecting to it with psql, the CLI tool for connecting to Postgres. 58 | 59 | ```bash 60 | # Only run this if you don't have the container running. It'll error otherwise 61 | docker run -e POSTGRES_PASSWORD=lol --name=pg --rm -d -p 5432:5432 postgres:14 62 | 63 | docker exec -u postgres -it pg psql 64 | ``` 65 | 66 | Now you should be connected to Postgres and ready to run queries on a fresh Postgres instance! 67 | 68 | [fem]: https://frontendmasters.com/courses/complete-intro-containers/ 69 | [docker]: https://www.docker.com/products/docker-desktop/ 70 | [linux]: https://frontendmasters.com/courses/linux-command-line/ 71 | [btholt]: https://hub.docker.com/r/btholt/complete-intro-to-sql 72 | [pg]: https://hub.docker.com/_/postgres/ 73 | [omdb]: https://www.omdbapi.com/ 74 | [credativ]: https://github.com/credativ/omdb-postgresql 75 | -------------------------------------------------------------------------------- /lessons/02-databases-and-tables/A-databases.md: -------------------------------------------------------------------------------- 1 | Let's get started by making our very first database. For our first project we are going to be making a recipe website. 2 | 3 | > If you haven't gotten your Docker container with Postgres 14 running in the previous lesson, please do that now. 4 | 5 | Let's connect to the running database by running the following: 6 | 7 | ```bash 8 | docker exec -u postgres -it pg psql 9 | ``` 10 | 11 | This should connects us to the running `pg` (which we named as such) container as the user `postgres` in an interactive session (via the `-it` flags) to run the bash command `psql`. If you were just running this locally, you could likely just run `psql` and it would work. Only thing you'd need to make sure you can auth correctly. 12 | 13 | ## psql commands 14 | 15 | psql has a bunch of built in commands to help you navigate around. All of these are going to begin with `\`. Try `\?` to bring up the help list. 16 | 17 | For now we're interested in what databases we have available to us. Try running `\l` to list all databases. You'll likely see the databases postgres, template0, and template1. 18 | 19 | ## Default databases 20 | 21 | `template1` is what Postgres uses by default when you create a database. If you want your databases to have a default shape, you can modify template1 to suit that. 22 | 23 | `template0` should never be modified. If your template1 gets out of whack, you can drop it and recreate from the fresh template0. 24 | 25 | `postgres` exists for the purpose of a default database to connect to. We're actually connected to it by default since we didn't specify a database to connect to. You can technically could delete it but there's no reason to and a lot of tools and extensions do rely on it being there. 26 | 27 | ## Create your own 28 | 29 | Okay let's create our own. Run this command in your database. 30 | 31 | ```sql 32 | CREATE DATABASE recipeguru; 33 | ``` 34 | 35 | > Note if you kill your container, it will kill all the data with it and you will need to recreate everything if you do that. I do provide [this complete SQL file][sql-file] that has _everything_ for the recipeguru database but it's for the whole lesson so if you do need to restart it may be easier to go back and do the creation by hand. You can also just use the [container from Docker Hub][hub]. 36 | 37 | A database is a collection of similar tables of data. For our recipe app we will store all our tables in one database. There's a lot of schools of thought of when to shove everything into one database versus when to decompose it all into various different databases. I generally think in terms of clients: if my app is going to be querying for the data, then I'll try to keep it in an app-oriented database. If I have another client that is going to be storing client analytics for my team to digest later, I'd probably put that in a separate database. It's a bit vague of when you should, but think in terms of "what if I had to scale these independently" sorts of terms. 38 | 39 | Type `\c recipeguru` to connect to our database. We were previously connected to the `postgres` default database. 40 | 41 | > You may notice that I use all capitals when I write SQL terminology e.g. CREATE DATABASE or SELECT or INSERT INTO. Why? It's not required and lots of people _never_ do the all caps thing so it's an opinion. I'm doing it for you because I think it makes it easier to say "this comes from SQL and this doesn't". You are free to decide what you think. In general in code I'll do the all caps but if I'm just messing around in psql I'll do lowercase. 42 | 43 | [sql-file]: https://sql.holt.courses/recipes.sql 44 | [hub]: /lessons/welcome/docker 45 | -------------------------------------------------------------------------------- /lessons/02-databases-and-tables/B-tables.md: -------------------------------------------------------------------------------- 1 | Let's create our first table, the `ingredients` table. 2 | 3 | ```sql 4 | CREATE TABLE ingredients ( 5 | id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 6 | title VARCHAR ( 255 ) UNIQUE NOT NULL 7 | ); 8 | ``` 9 | 10 | > INT, INT4 or INTEGER are the same thing. Feel free to use what suits you. Similarly DEC, FIXED, and DECIMAL are the same thing. 11 | 12 | This will create our first table for us to start using. We will get into data types during the course but just know now that a VARCHAR is a string of max length 255 characters and an INTEGER is, well, an integer. 13 | 14 | To see the table you created, run `\d` in your psql instance to see it and the the sequence that you created. The sequence stores the `id` counter. 15 | 16 | We now have a table. A table is the actual repository of data. Think of a database like a folder and a table like a spreadsheet. You can have many spreadsheets in a folder. Same with tables. 17 | 18 | We now have a table, ingredients. Our table has two fields in it, an incrementing ID and a string that is the the title of the ingredients. You can think of fields like columns in a spreadsheet. 19 | 20 | A table contains records. A record can be thought of as a row in a spreadsheet. Every time we insert a new record into a table, we're adding another row to our spreadsheet. 21 | 22 | > The spreadsheet analogy isn't just theoretical. [You can essentially use Google Sheets as a database][sheets] (appropriate for small, infrequent use.) 23 | 24 | Let's add a record to our table. 25 | 26 | ```sql 27 | INSERT INTO ingredients (title) VALUES ('bell pepper'); 28 | ``` 29 | 30 | > We'll get more into these queries in a sec. For now just roll with it. 31 | 32 | This adds one row with a title of bell pepper. Where is the id? Since we made it `GENERATED ALWAYS AS IDENTITY` it gets created automatically. Since this is the first item in our database, its ID will be `1`. As you have likely guessed already, the next item in the table will be `2`. 33 | 34 | > Previously PostgreSQL used a field type called SERIAL to describe the serially incrementing IDs. The GENERATED AS ALWAYS syntax is newer, more compliant to the generic SQL spec, and works better. Always use it over SERIAL. 35 | 36 | Let's see the record. 37 | 38 | ```sql 39 | SELECT * FROM ingredients; 40 | ``` 41 | 42 | > We'll dive into SELECTs in a bit. 43 | 44 | You should see something like 45 | 46 | ```plaintext 47 | id | title 48 | ----+------------- 49 | 1 | bell pepper 50 | ``` 51 | 52 | Amazing! We now have a table with a record in it. 53 | 54 | ## Dropping a table 55 | 56 | What if we messed up and we didn't want an ingredients table? 57 | 58 | ```sql 59 | DROP TABLE ingredients; 60 | ``` 61 | 62 | Pretty simple, right? That's it! Do be careful with this command. Like `rm` in bash, it's not one you can recover from. Once a table is dropped, it is dropped. 63 | 64 | ## ALTER TABLE 65 | 66 | Let's recreate our table. 67 | 68 | ```sql 69 | CREATE TABLE ingredients ( 70 | id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 71 | title VARCHAR ( 255 ) UNIQUE NOT NULL 72 | ); 73 | ``` 74 | 75 | Okay so now we have a table again. What happens if we wanted to add a third field to our table? Let's add an `image` field that will point to a URL of an image of the item. 76 | 77 | ```sql 78 | ALTER TABLE ingredients ADD COLUMN image VARCHAR ( 255 ); 79 | ``` 80 | 81 | Likewise we can drop it too: 82 | 83 | ```sql 84 | ALTER TABLE ingredients DROP COLUMN image; 85 | ``` 86 | 87 | There's a lot of ways to alter a table. You can make it UNIQUE like we did or NOT NULL. You can also change the data type. For now let's add back our extra column. 88 | 89 | ```sql 90 | ALTER TABLE ingredients 91 | ADD COLUMN image VARCHAR ( 255 ), 92 | ADD COLUMN type VARCHAR ( 50 ) NOT NULL DEFAULT 'vegetable'; 93 | ``` 94 | 95 | This is how you add multiple records! Just add multiple "ADD COLUMN"s. As you may have guessed, you can do multiple different types of operations if you need to in one atomic transaction. 96 | 97 | > Specifying a DEFAULT when using a NOT NULL constraint will prevent errors if the column has existing null values. 98 | 99 | ## Data types 100 | 101 | There are so many data types in PostgreSQL that we won't get close to covering them all. [Have a peek here from the PostgreSQL docs][types] 102 | 103 | [sheets]: https://www.npmjs.com/package/google-spreadsheet 104 | [types]: https://www.postgresql.org/docs/current/datatype.html 105 | -------------------------------------------------------------------------------- /lessons/02-databases-and-tables/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "database" 3 | } -------------------------------------------------------------------------------- /lessons/03-data/A-inserts.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | From the previous lesson we have an ingredients table in our recipeguru database. It's basically an empty spreadsheet at this point. It's a repository of data with nothing in it. Let's add our first bit of data into it. 6 | 7 | ```sql 8 | INSERT INTO ingredients ( 9 | title, image, type 10 | ) VALUES ( 11 | 'red pepper', 'red_pepper.jpg', 'vegetable' 12 | ); 13 | ``` 14 | 15 | This is the standard way of doing an insert. In the first set of parens you list out the column names then in the values column you list the actual values you want to insert. 16 | 17 | Big key here which will throw JS developers for a loop: you _must_ use single quotes. Single quotes in SQL means "this is a literal value". Double quotes in SQL mean "this is an identifier of some variety". 18 | 19 | ```sql 20 | INSERT INTO "ingredients" ( 21 | "title", "image", "type" -- Notice the " here 22 | ) VALUES ( 23 | 'broccoli', 'broccoli.jpg', 'vegetable' -- and the ' here 24 | ); 25 | ``` 26 | 27 | > Use `--` for comments 28 | 29 | The above query works because the double quotes are around identifiers like the table name and the column names. The single quotes are around the literal values. The double quotes above are optional. The single quotes are not. 30 | 31 | Okay, let's insert a few more. 32 | 33 | ```sql 34 | INSERT INTO ingredients ( 35 | title, image, type 36 | ) VALUES 37 | ( 'avocado', 'avocado.jpg', 'fruit' ), 38 | ( 'banana', 'banana.jpg', 'fruit' ), 39 | ( 'beef', 'beef.jpg', 'meat' ), 40 | ( 'black_pepper', 'black_pepper.jpg', 'other' ), 41 | ( 'blueberry', 'blueberry.jpg', 'fruit' ), 42 | ( 'broccoli', 'broccoli.jpg', 'vegetable' ), 43 | ( 'carrot', 'carrot.jpg', 'vegetable' ), 44 | ( 'cauliflower', 'cauliflower.jpg', 'vegetable' ), 45 | ( 'cherry', 'cherry.jpg', 'fruit' ), 46 | ( 'chicken', 'chicken.jpg', 'meat' ), 47 | ( 'corn', 'corn.jpg', 'vegetable' ), 48 | ( 'cucumber', 'cucumber.jpg', 'vegetable' ), 49 | ( 'eggplant', 'eggplant.jpg', 'vegetable' ), 50 | ( 'fish', 'fish.jpg', 'meat' ), 51 | ( 'flour', 'flour.jpg', 'other' ), 52 | ( 'ginger', 'ginger.jpg', 'other' ), 53 | ( 'green_bean', 'green_bean.jpg', 'vegetable' ), 54 | ( 'onion', 'onion.jpg', 'vegetable' ), 55 | ( 'orange', 'orange.jpg', 'fruit' ), 56 | ( 'pineapple', 'pineapple.jpg', 'fruit' ), 57 | ( 'potato', 'potato.jpg', 'vegetable' ), 58 | ( 'pumpkin', 'pumpkin.jpg', 'vegetable' ), 59 | ( 'raspberry', 'raspberry.jpg', 'fruit' ), 60 | ( 'red_pepper', 'red_pepper.jpg', 'vegetable' ), 61 | ( 'salt', 'salt.jpg', 'other' ), 62 | ( 'spinach', 'spinach.jpg', 'vegetable' ), 63 | ( 'strawberry', 'strawberry.jpg', 'fruit' ), 64 | ( 'sugar', 'sugar.jpg', 'other' ), 65 | ( 'tomato', 'tomato.jpg', 'vegetable' ), 66 | ( 'watermelon', 'watermelon.jpg', 'fruit' ) 67 | ON CONFLICT DO NOTHING; 68 | ``` 69 | 70 | > Feel free to copy and paste this. Too much typing. 71 | 72 | This is the way to do many inserts at once, just by comma separating sets in the VALUES part. 73 | 74 | Note the `ON CONFLICT` section. Some of these you may have already inserted (like the red pepper.) This is telling PostgreSQL that if a row exists already to just do nothing about it. We could also do something like: 75 | 76 | ```sql 77 | INSERT INTO ingredients ( 78 | title, image, type 79 | ) VALUES 80 | ( 'watermelon', 'banana.jpg', 'this won''t be updated' ) 81 | ON CONFLICT (title) DO UPDATE SET image = excluded.image; 82 | ``` 83 | 84 | This is what many of us would call an "upsert". Insert if that title doesn't exist, update if it does. If you try to run that query (or the previous one) without the ON CONFLICT statement, it will fail since we asserted that title is a UNIQUE field; there can only be one of that exact field in the database. 85 | 86 | The type won't be updated because we didn't choose to handle that. 87 | 88 | Also notice we did two `'` in a row (and not a double quote, but two single quotes in a row.) Because " and ' have different meanings in SQL, we have to account for that. In this case we do that if we want to have a single quote in our string. 89 | -------------------------------------------------------------------------------- /lessons/03-data/B-updates-and-deletes.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | The next thing we need to learn is how to update a field. We've seen how to INSERT and do an update if an INSERT fails, but let's see how to update something directly. 6 | 7 | ```sql 8 | UPDATE ingredients SET image = 'watermelon.jpg' WHERE title = 'watermelon'; 9 | ``` 10 | 11 | The WHERE clause is where you filter down what you want to update. In this case there's only one watermelon so it'll just update one but if you had many watermelons it would match all of those and update all of them, 12 | 13 | You probably got a line returned something like 14 | 15 | ```plaintext 16 | UPDATE 1 17 | ``` 18 | 19 | If you want to return what was updated try: 20 | 21 | ```sql 22 | UPDATE ingredients SET image = 'watermelon.jpg' WHERE title = 'watermelon' RETURNING id, title, image; 23 | ``` 24 | 25 | The RETURNING clause tells Postgres you want to return those columns of the things you've updated. In our case I had it return literally everything we have in the table so you could write that as 26 | 27 | ```sql 28 | UPDATE ingredients SET image = 'watermelon.jpg' WHERE title = 'watermelon' RETURNING *; 29 | ``` 30 | 31 | The \* means "everything" in this case. 32 | 33 | Let's add two rows with identical images. 34 | 35 | ```sql 36 | INSERT INTO ingredients 37 | (title, image) 38 | VALUES 39 | ('not real 1', 'delete.jpg'), 40 | ('not real 2', 'delete.jpg'); 41 | ``` 42 | 43 | > Whitespace isn't significant in SQL. You can break lines up however you want and how they make sense to you. 44 | 45 | Now let's update both. 46 | 47 | ```sql 48 | UPDATE ingredients 49 | SET image = 'different.jpg' 50 | WHERE image='delete.jpg' 51 | RETURNING id, title, image; 52 | ``` 53 | 54 | Here you can see as long as that WHERE clause matches multiple rows, it'll update all the rows involved. 55 | 56 | ## DELETE 57 | 58 | Deletes are very similar in SQL. 59 | 60 | ```sql 61 | DELETE FROM ingredients 62 | WHERE image='different.jpg' 63 | RETURNING *; 64 | ``` 65 | 66 | Here we just have no SET clause. Anything that matches that WHERE clause will be deleted. The RETURNING, like in updates, is optional if you want to see what was deleted. 67 | -------------------------------------------------------------------------------- /lessons/03-data/C-selects.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | Let's next hop into SELECT, or how to read data from a database. We've already seen the most basic one. 6 | 7 | ```sql 8 | SELECT * FROM ingredients; 9 | ``` 10 | 11 | The \* there represents all available columns. Frequently, for a variety of reasons, we do not want to select everything. In general, I would recommend only using \* where your intent is truly "I want _anything this database could ever store for these records_". Frequently that is not the case. Frequently it is "I need the name, address and email for this email, but not their social security or credit card number". This is positive because it means smaller transfer loads, that your system only processes data it needs and not the rest, and it shows intention in your code. Honestly the biggest benefit is the latter: to show intentions in your code. 12 | 13 | So in our case we could put 14 | 15 | ```sql 16 | SELECT id, title, image, type FROM ingredients; 17 | ``` 18 | 19 | When you read this now you know that the former coder was actively looking for those three columns. 20 | 21 | ## LIMIT and OFFSET 22 | 23 | Okay, now what if the user only wants five records? 24 | 25 | ```sql 26 | SELECT id, title, image 27 | FROM ingredients 28 | LIMIT 5; 29 | ``` 30 | 31 | This will limit your return to only five records, the first five it finds. You frequently will need to do this as well (as in almost always) since a database can contain _millions_ if not _billions_ of records. 32 | 33 | So what if you want the next five records? 34 | 35 | ```sql 36 | SELECT id, title, image 37 | FROM ingredients 38 | LIMIT 5 39 | OFFSET 5; 40 | ``` 41 | 42 | This can be inefficient at large scales and without the use of indexes. It also has the problem of if you're paging through data and someone inserts a record in the meantime you could shift your results. We'll get into optimizing queries a bit later, but just be aware of that. 43 | 44 | In our exercise that is upcoming, feel free to use OFFSET. 45 | 46 | ## WHERE 47 | 48 | Sometimes you don't want all of the records all at once. In that case you can add a WHERE clause where you tell the database to filter your results down to a subset of all of your records. What if we only wanted to show only fruits? 49 | 50 | ```sql 51 | SELECT * 52 | FROM ingredients 53 | WHERE type = 'fruit'; 54 | ``` 55 | 56 | This will give us all the fruits we had. What if we wanted to only select vegetables where the the IDs are less 20? 57 | 58 | ```sql 59 | SELECT * 60 | FROM ingredients 61 | WHERE type = 'vegetable' 62 | AND id < 20; 63 | ``` 64 | 65 | AND allows you to add multiple clauses to your selects. As you may guess, the presence of AND belies the existance of OR 66 | 67 | ```sql 68 | SELECT * 69 | FROM ingredients 70 | WHERE id <= 10 71 | OR id >= 20; 72 | ``` 73 | 74 | ## ORDER BY 75 | 76 | You frequently will care about the order these things come back in, how they're sorted. That's what ORDER BY is for. 77 | 78 | Let's say you wanted to order not by id or insertion order but by title. 79 | 80 | ```sql 81 | SELECT * FROM ingredients ORDER BY title; 82 | ``` 83 | 84 | This will alphabetize your returned list. What if we wanted it in reverse order of IDs? 85 | 86 | ```sql 87 | SELECT * FROM ingredients ORDER BY id DESC; 88 | ``` 89 | 90 | This will start at the largest number and count backwards. As you may have guessed, `ASC` is implied if you don't specify. 91 | -------------------------------------------------------------------------------- /lessons/03-data/D-like-and-functions.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | --- 4 | 5 | Some times you want to search for something in your database but it's not an exact match. The best example would be if a user types something into the search bar, they're likely not giving you something that you match _exactly_. You would expect a search for "pota" to match "potato", right? Enter `LIKE` in SQL. 6 | 7 | ```sql 8 | SELECT * FROM ingredients WHERE title LIKE '%pota%'; 9 | ``` 10 | 11 | This is a very limited fuzzy matching of text. This is not doing things like dropping "stop words" (like and, the, with, etc.) or handling plurals, or handling similar spellings (like color vs colour). Postgres _can_ do this, and we'll get there later with indexes. 12 | 13 | ## Built in functions 14 | 15 | Okay, great, now what if a user searchs for "fruit"? We'd expect that to work, right? 16 | 17 | ```sql 18 | SELECT * FROM ingredients WHERE CONCAT(title, type) LIKE '%fruit%'; 19 | ``` 20 | 21 | `concat()` is a function that will take two strings and combine them together. We can concat our two title and type columns and then use LIKE on the results on that combined string. 22 | 23 | > The result of the cherry row would be `cherryfruit` which means it'd match weird strings like `rryfru`. We'll talk later about more complicated string matching but this will do for now. 24 | 25 | Okay, but what if we have capitalization problem? We can use lower, both on the columns and on the values. 26 | 27 | ```sql 28 | SELECT * FROM ingredients WHERE LOWER(CONCAT(title, type)) LIKE LOWER('%FrUiT%'); 29 | ``` 30 | 31 | `LOWER()` take a string and make it lowercase. 32 | 33 | Fortunately, there's an even easier way to do this with less function evocation. 34 | 35 | ```sql 36 | SELECT * FROM ingredients WHERE CONCAT(title, type) ILIKE '%FrUiT%'; 37 | ``` 38 | 39 | `ILIKE` does the same thing, just with case insensitivity. Mostly I just wanted to show you lower! 40 | 41 | There are _so many_ built in functions to Postgres. [Click here to see the official docs.][pg] 42 | 43 | ## % vs \_ 44 | 45 | You see that we've been surrounding the `LIKE` values with `%`. This is basically saying "match 0 to infinite characters". So with "%berry" you would match "strawberry" and "blueberry" but not "berry ice cream". Because the "%" was only at the beginning it wouldn't match anything after, you'd need "%berry%" to match both "strawberry" and "blueberry ice cream". 46 | 47 | ```sql 48 | SELECT * FROM ingredients WHERE title ILIKE 'c%'; 49 | ``` 50 | 51 | The above will give you all titles that start with "c". 52 | 53 | You can put % anywhere. "b%t" will match "bt", "bot", "but", "belt", and "belligerent". 54 | 55 | There also exists `_` which will match 1 and only one character. "b_t" will match "bot" and "but" but not "bt", "belt", or "belligerent". 56 | 57 | [pg]: https://www.postgresql.org/docs/9.2/functions.html 58 | -------------------------------------------------------------------------------- /lessons/03-data/E-nodejs-and-postgresql.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Node.js and PostgreSQL" 3 | --- 4 | 5 | This is not a Node.js class so the expectation of your Node.js skills here are fairly low. No worries if you don't have a ton of experience with Node.js 6 | 7 | We are going to be using the [pg][pg] package which is [the most common PostgreSQL client][trends] for Node.js. There are others but this is the one with the most direct access to the underlying queries which is what we want. Feel free to use an ORM in your projects but it's useful to know how the underlying PostgreSQL works. 8 | 9 | Before I dump you into the code I wanted to give you a very brief tour of what to expect. 10 | 11 | ## Connecting to a database 12 | 13 | pg has two ways to connect to a database: a client and a pool. 14 | 15 | You can think of a client as an individual connection you open and then eventually close to your PostgreSQL database. When you call `connect` on a client, it handshakes with the server and opens a connection for you to start using. You eventually call `end` to end your session with a client. 16 | 17 | A pool is a pool of clients. Opening and closing clients can be expensive if you're doing it a lot. Imagine you have a server that gets 1,000 requests per second and every one of those needs to open a line to a database. All that opening and closing of connections is a lot of overhead. A pool therefore holds onto connections. You basically just say "hey pool, query this" and if it has a spare connection open it will give it to you. If you don't then it will open another one. Pretty neat, right? 18 | 19 | Okay, we're going to be using pools today since we're doing a web server. I'd really only use a client for a one-off script or if I'm doing transactions which aren't supported by pools (transactions are not covered in this course but it's if multiple queries _have_ to happen all at once or not all). Otherwise I'd stick to pools. One isn't any harder to use than the other. 20 | 21 | So let's see how to connect to a database. 22 | 23 | ```javascript 24 | const pg = require("pg"); 25 | const pool = new pg.Pool({ 26 | user: "postgres", 27 | host: "localhost", 28 | database: "recipeguru", 29 | password: "lol", 30 | port: 5432, 31 | }); 32 | ``` 33 | 34 | - These are the default credentials combined with what I set up in the previous lessons. Make sure you're using the correct credentials. 35 | - Make sure you started PostgreSQL via docker with the `-p 5432:5432` flag or PostgreSQL won't be exposed on your host system. 36 | - Make sure your database is running too. 37 | 38 | Once you've done this, you can now start making queries to PostgreSQL! 39 | 40 | Let's write a query. 41 | 42 | ```javascript 43 | const { rows } = await pool.query(`SELECT * FROM recipes`); 44 | ``` 45 | 46 | - `rows` will be the response of the query. 47 | - There's lot of other metadata on these queries beyond just the rows. Feel free to `console.log` it or `debug` it to explore. 48 | - pg will add the `;` at the end for you. 49 | 50 | ## Parameterization and SQL injection 51 | 52 | Let's say you wanted to query for one specific ingredient by `id` and that id was passed in via an AJAX request. 53 | 54 | ```sql 55 | SELECT * FROM ingredients WHERE id = ; 56 | ``` 57 | 58 | What's wrong with then doing then: 59 | 60 | ```javascript 61 | const { id } = req.query; 62 | const { rows } = await pool.query(`SELECT * FROM ingredients WHERE id=${id}`); 63 | ``` 64 | 65 | SQL injection. You're just raw passing in user data to a query and a user could fathomably put _anything_ in that id API request. What happens if a user made id equal to `1; DROP TABLE users; --`? 66 | 67 | > Do not run the next query. Well, it wouldn't matter since we don't have a users table but still, it's meant to be a demonstration of what could happen. 68 | 69 | ```sql 70 | SELECT * FROM ingredients WHERE id=1; DROP TABLE users; -- 71 | ``` 72 | 73 | Oh no 😱 74 | 75 | The first `1;` ends the first query. The `DROP TABLE users;` does whatever malicious thing the hacker wants to do. They can delete info like this one does, or they can dump info that you don't want them to. The `--` says "everything after this is a comment" so it comments out the rest of your SQL if anything came after it. 76 | 77 | This is SQL injection and a very real issue with SQL in apps. It's why Wordpress apps are always under attack because they use MySQL behind the scenes. If you can find somewhere you can inject SQL you can get a lot of info out of an app. 78 | 79 | So how do we avoid it? Sanitization of your inputs and parameterization of your queries. 80 | 81 | ```javascript 82 | const { id } = req.query; 83 | 84 | const { rows } = await pool.query(`SELECT * FROM ingredients WHERE id=$1`, [ 85 | id, 86 | ]); 87 | ``` 88 | 89 | If you do this, pg will handle making sure that what goes into the query is safe to send to your database. Easy enough, right? 90 | 91 | > If you need to do a a thing like `WHERE text ILIKE '%star wars%'` and you're using parameterized queryies, just put the `%` in the thing to be inserted, e.g. `('WHERE text ILIKE $1', ['%star wars%'])` 92 | 93 | [pg]: https://node-postgres.com/ 94 | [trends]: https://npmtrends.com/knex-vs-objection-vs-pg-vs-sequelize-vs-typeorm 95 | -------------------------------------------------------------------------------- /lessons/03-data/F-project.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | Project time! 6 | 7 | Let's do a Node.js project. Hopefully the Node.js part is minimal but it will require JavaScript skills. 8 | 9 | [Go clone this repo from GitHub][project]. In this repo you will find our coding projects, one for the ingredients part and one for the recipes part project which we will do later. 10 | 11 | You will be working from the ingredients folder. You can look at the other files but you should only need to modify api.js. You can modify the others but it shouldn't be necessary. 12 | 13 | You'll find the starter project and then in the `completed` folder the totally completed project. The latter is for you to use to compare my answer to use. Like coding, there are many correct ways to write SQL and almost always trade offs. The goal is learning, so if it helps you to look at the answer, do it! There's no cheating here, only learning. Don't cheat yourself of learning. 14 | 15 | We're going to go to the ingredients page first (we're doing recipes next.) Go to the ingredients/api.js part of the app and start working on the answers. 16 | 17 | > - Make sure your Postgres Docker database is running by running `docker ps` or looking at the Docker GUI 18 | > - Make sure that your database is exposing port 5432. You can see that in the `docker ps` output or in the GUI. 19 | > - Make sure you're connecting to the correct database. If you didn't create a new database, the data you created will be in the default `postgres` database. If you followed my instructions, the data will be in the `recipeguru` database. 20 | > - If your database is missing the data, go back to [the INSERTs lesson][inserts] and copy the long query there and rerun it in your database. 21 | 22 | ## Your project 23 | 24 | 1. Make sure your database is connecting correctly with the credential you're supplying. 25 | 1. Make the various ingredient carousels work. This will involve selecting the right type. 26 | 1. Make the full text search work. 27 | 1. Make the pagination work for search. You can use OFFSET here. Doing it with IDs is more correct, but a stretch goal here. 28 | 29 | ## Tips 30 | 31 | - You shouldn't have to modify the HTML, CSS, or client.js file (unless you do the pagination by ID in which you'd need to modify client.js) 32 | - You shouldn't have to modify any Node.js besides the function bodies of the handlers, and most of the skeleton of that is done for you. You really only need to query Postgres. 33 | - We're using the `pg` module in Node.js for Postgres. It's by far the most popular. [See documentation here][pg]. 34 | 35 | ## We need an aggregation function 36 | 37 | Okay, I need you to add one little thing to your query to get this all to work correctly, but we're not that far in the lesson yet. 38 | 39 | Try this query in your CLI. 40 | 41 | ```sql 42 | SELECT id, title, COUNT(*) OVER ()::INT AS total_count FROM ingredients; 43 | ``` 44 | 45 | The `COUNT(*) OVER ()::INTEGER AS total_count` is going to return the total count of all rows in this query as an integer and return it as total_count. Please pass this total_count to the frontend so pagination will work. We'll go over this later, but please add that to your query so the pagination on the search results works. 46 | 47 | [project]: https://github.com/btholt/sql-apps 48 | [pg]: https://node-postgres.com/ 49 | [inserts]: /lessons/data/inserts 50 | -------------------------------------------------------------------------------- /lessons/03-data/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "table" 3 | } -------------------------------------------------------------------------------- /lessons/04-joins-and-constraints/A-relationships.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | So far we've done a one to one matching of records. We've used a record in a database to represent one item: a vegetable, a fruit, so on and so forth. 6 | 7 | Now we're going to get into records that can relate to each other. Let's think about recipes. A recipe has multiple ingredients. That **has** word is key here. It means there is a relationship. A single recipe has many ingredients. An ingredient can also be in many recipes. A tomato is both in pizza sauce and in a BLT. This is called a many-to-many relationship. 8 | 9 | There's also one-to-many relationships, like imagine if we had multiple photos of each of our ingredients. A single ingredient will have five photos. And those photos will only will only belong to one ingredient. A photo of a green pepper doesn't make sense to belong to anything besides the green pepper ingredient. 10 | 11 | There can also exist one-to-one relationships but in general you would just make those the same record all together. You could split up the type and title into two tables, but why would you. 12 | 13 | ## A basic relationship 14 | 15 | We're going to do this a naïve wrong way, and then we're going to do it the correct way. 16 | 17 | First thing we're going to make a recipes table 18 | 19 | ```sql 20 | CREATE TABLE recipes ( 21 | recipe_id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 22 | title VARCHAR ( 255 ) UNIQUE NOT NULL, 23 | body TEXT 24 | ); 25 | ``` 26 | 27 | Okay, no rocket science so far. We just created another random table. Let's say we could have many images of a single recipe. How would we go about doing that? 28 | 29 | ```sql 30 | INSERT INTO recipes 31 | (title, body) 32 | VALUES 33 | ('cookies', 'very yummy'), 34 | ('empanada','ugh so good'), 35 | ('jollof rice', 'spectacular'), 36 | ('shakshuka','absolutely wonderful'), 37 | ('khachapuri', 'breakfast perfection'), 38 | ('xiao long bao', 'god I want some dumplings right now'); 39 | ``` 40 | 41 | > Pro tip: don't write a course while hungry. 42 | 43 | Okay, we now have a bunch of recipes. Our problem now is that we have a variable amount of photos for each recipe. Perhaps we have one picture of cookies but we have four pictures of jollof rice. How do we model that if we always have a fixed amount columns? 44 | 45 | One approach would be to say "I only have at most five pictures of any recipes, I'll make columns pic1 through pic5 columns and limit myself to that". Totally valid approach, and if you can guarantee only 5 images at most, it might be the right approach. 46 | 47 | But what if we can't guarantee that? We need some sort of flexibility. This is where we can use two tables. 48 | 49 | ```sql 50 | CREATE TABLE recipes_photos ( 51 | photo_id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 52 | recipe_id INTEGER, 53 | url VARCHAR(255) NOT NULL 54 | ); 55 | ``` 56 | 57 | Now we can insert a some rows to reference images that we have. 58 | 59 | ```sql 60 | INSERT INTO recipes_photos 61 | (recipe_id, url) 62 | VALUES 63 | (1, 'cookies1.jpg'), 64 | (1, 'cookies2.jpg'), 65 | (1, 'cookies3.jpg'), 66 | (1, 'cookies4.jpg'), 67 | (1, 'cookies5.jpg'), 68 | (2, 'empanada1.jpg'), 69 | (2, 'empanada2.jpg'), 70 | (3, 'jollof1.jpg'), 71 | (4, 'shakshuka1.jpg'), 72 | (4, 'shakshuka2.jpg'), 73 | (4, 'shakshuka3.jpg'), 74 | (5, 'khachapuri1.jpg'), 75 | (5, 'khachapuri2.jpg'); 76 | -- no pictures of xiao long bao 77 | ``` 78 | 79 | Okay, now let's select all the photos for shakshuka. Shakshuka is ID 4 (if you followed my instructions, feel free to drop tables and recreate them if you need to.) 80 | 81 | ```sql 82 | SELECT title, body FROM recipes WHERE recipe_id = 4; 83 | SELECT url FROM recipes_photos WHERE recipe_id = 4; 84 | ``` 85 | 86 | It'd be cool if we could do these all at the same time. Some sort inner section of a venn diagram, right? You can! 87 | 88 | ```sql 89 | SELECT recipes.title, recipes.body, recipes_photos.url 90 | FROM recipes_photos 91 | INNER JOIN 92 | recipes 93 | ON 94 | recipes_photos.recipe_id = recipes.recipe_id 95 | WHERE recipes_photos.recipe_id = 4; 96 | ``` 97 | 98 | Amazing!! 99 | 100 | Now we're getting data from each table smushed together. The data from recipes will be repeated over item in the recipes_photos table but that's what we expect. 101 | 102 | Just to show you some common shorthand (but otherwise same query) 103 | 104 | ```sql 105 | SELECT r.title, r.body, rp.url 106 | FROM recipes_photos rp 107 | INNER JOIN 108 | recipes r 109 | ON 110 | rp.recipe_id = r.recipe_id 111 | WHERE rp.recipe_id = 4; 112 | ``` 113 | 114 | Go ahead and try this to see the full inner join. 115 | 116 | ```sql 117 | SELECT r.title, r.body, rp.url 118 | FROM recipes_photos rp 119 | INNER JOIN 120 | recipes r 121 | ON 122 | rp.recipe_id = r.recipe_id; 123 | ``` 124 | 125 | Pretty cool, right? 126 | -------------------------------------------------------------------------------- /lessons/04-joins-and-constraints/B-other-types-of-joins.md: -------------------------------------------------------------------------------- 1 | Notice in our query above that "xiao long bao" does not show up at all. Makes sense, we have no photos of xiao long bao so why would they show up? INNER JOIN was the correct choice for what semantics we intended. 2 | 3 | Okay, but let's say we were populating a list of all our recipes and their photos and we had a default image if a recipe didn't have any photos? Then INNER JOIN doesn't make sense because INNER JOIN only gives up things where they exist in both table A and table B. So how would we accomplish this task then? 4 | 5 | ```sql 6 | SELECT r.title, r.body, rp.url 7 | FROM recipes_photos rp 8 | RIGHT OUTER JOIN 9 | recipes r 10 | ON 11 | rp.recipe_id = r.recipe_id; 12 | ``` 13 | 14 | Or just 15 | 16 | ```sql 17 | SELECT r.title, r.body, rp.url 18 | FROM recipes_photos rp 19 | RIGHT JOIN 20 | recipes r 21 | ON 22 | rp.recipe_id = r.recipe_id; 23 | ``` 24 | 25 | (The OUTER is optional.) 26 | 27 | So what is this? This is saying "join everything has a match (aka everything in INNER JOIN) as a row. Leave out everything in the table in the `FROM` clause that doesn't have a match (which in this case would mean leave out any photos that aren't matched to a recipe. We don't have any anyway and shouldn't. Orphan photos would just be bloat.) 28 | 29 | The `RIGHT` part of this means "include all recipes without photos". That's what the RIGHT part means. If you wanted any photos included that didn't have recipes, you guessed it, you'd use `LEFT`. 30 | 31 | What if we want both? 32 | 33 | ```sql 34 | SELECT r.title, r.body, rp.url 35 | FROM recipes_photos rp 36 | FULL OUTER JOIN 37 | recipes r 38 | ON 39 | rp.recipe_id = r.recipe_id; 40 | ``` 41 | 42 | This is a combination of LEFT and RIGHT join, pluse the INNER. 43 | 44 | [![diagram of SQL joins](../images/SQL_Joins.png)](https://commons.wikimedia.org/wiki/File:SQL_Joins.svg) 45 | 46 | As you can see here in this diagram, you can select for any overlap of the two Venn diagrams. They're all "correct"; it just depends what you mean to select. There's no one correct way. That's like asking "is + or - correct?" Well, it depends on what you're trying to do! 47 | 48 | ## NATURAL JOIN 49 | 50 | I intentionally named `recipe_id` in both tables the same to show you this fun party trick. 51 | 52 | ```sql 53 | SELECT * 54 | FROM recipes_photos 55 | NATURAL JOIN 56 | recipes; 57 | ``` 58 | 59 | What is this sorcery!? Because we named recipe_id the same in both, Postgres is smart enough to put two and two together and figure out that that's what we should join on. 60 | 61 | `NATURAL JOIN` is short for `NATURAL INNER JOIN`. `NATURAL LEFT JOIN` and `NATURAL RIGHT JOIN` are also possible too. 62 | 63 | In general let me steer you away from this in your code. Your tables can change down the line and you could accidentally name things the same that aren't the same (like `id` being a classic one.) It's useful for quick querying like we're doing, but I'd say be explicit and avoid NATURAL's implicit behavior. Still cool though, right? 64 | 65 | ## CROSS JOIN 66 | 67 | A small note on a not super useful type of JOIN. Let's say you had two tables. In table 1 you had colors: green, blue, and red. In table 2 you have animals: dog, cat, and chicken. You want to make every possible permutation of the combination of both tables: green dog, green cat, green chicken, blue dog, blue cat, etc. There's an ability to do this with CROSS JOIN 68 | 69 | ```sql 70 | SELECT r.title, r.body, rp.url 71 | FROM recipes_photos rp 72 | CROSS JOIN 73 | recipes r; 74 | ``` 75 | 76 | Keep in mind that 3 rows in each table yielded 9 rows. In our case, it yield 78 rows! It's not typically the most useful kind of join but good to know it's there. 77 | -------------------------------------------------------------------------------- /lessons/04-joins-and-constraints/C-foreign-keys.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | So far so good, we have the ability to query across tables and join them together. We have a problem though: what if we delete a recipe? 6 | 7 | ```sql 8 | DELETE 9 | FROM 10 | recipes r 11 | WHERE 12 | r.recipe_id = 5; 13 | -- The khachapuri 14 | ``` 15 | 16 | Cool, we dropped the recipe, but what about the photos? 17 | 18 | ```sql 19 | SELECT 20 | * 21 | FROM 22 | recipes_photos rp 23 | WHERE 24 | rp.recipe_id = 5; 25 | ``` 26 | 27 | Oh no! Still there! Now, we could write a second query to drop these two, but it'd be great if Postgres could track these changes for us and assure us they'd never fall out of sync. 28 | 29 | Let's start with a fresh slate really quick. 30 | 31 | ```sql 32 | DROP TABLE IF EXISTS recipes; 33 | DROP TABLE IF EXISTS recipes_photos; 34 | CREATE TABLE recipes ( 35 | recipe_id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 36 | title VARCHAR ( 255 ) UNIQUE NOT NULL, 37 | body TEXT 38 | ); 39 | INSERT INTO recipes 40 | (title, body) 41 | VALUES 42 | ('cookies', 'very yummy'), 43 | ('empanada','ugh so good'), 44 | ('jollof rice', 'spectacular'), 45 | ('shakshuka','absolutely wonderful'), 46 | ('khachapuri', 'breakfast perfection'), 47 | ('xiao long bao', 'god I want some dumplings right now'); 48 | ``` 49 | 50 | Now, let's see what happens if we modify recipes_photos really quick. 51 | 52 | ```sql 53 | CREATE TABLE recipes_photos ( 54 | photo_id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 55 | url VARCHAR(255) NOT NULL, 56 | recipe_id INTEGER REFERENCES recipes(recipe_id) ON DELETE CASCADE 57 | ); 58 | ``` 59 | 60 | Okay, so we have a few things here 61 | 62 | - The REFERENCES portion means it's going to be a foreign key. You tell it what it's going to match up to. In our case `recipes` is the table and `recipe_id` is the name of the column it'll match. In our case those are the same name, but it doesn't have to be. It must be the primary key of the other table. 63 | - Then you need to tell it what to do when you delete something. With `ON DELETE CASCADE` you say "if the row in the other table gets deleted, delete this one too." So if we delete something from recipes, it will automatically delete all its photos. Pretty cool, right? 64 | - You can also do `ON DELETE SET NULL` which does exactly what it says it does. There's also `ON DELETE NO ACTION` which will error out if you try to delete something from recipes if there are still photos left. This forces developers to clean up photos before deleting recipes. That can be helpful to. 65 | - There's also `ON UPDATE`s if you need to handle some synced state state between the two tables. 66 | 67 | If you're going to have have two tables reference each other, use foreign keys where possible. It makes useful constraints to make sure delete and update behaviors are intentional and it makes the queries faster. 68 | -------------------------------------------------------------------------------- /lessons/04-joins-and-constraints/D-many-to-many.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | title: "Many-to-Many Relationships" 4 | --- 5 | 6 | So let's now make our recipes connect to our ingredients! 7 | 8 | This is a bit different because it's a many-to-many relationship. A recipe has many ingredients, and an ingredient can belong to many recipes (eggs are in both khachapuri and cookies.) 9 | 10 | ```sql 11 | CREATE TABLE recipe_ingredients ( 12 | recipe_id INTEGER REFERENCES recipes(recipe_id) ON DELETE NO ACTION, 13 | ingredient_id INTEGER REFERENCES ingredients(id) ON DELETE NO ACTION, 14 | CONSTRAINT recipe_ingredients_pk PRIMARY KEY (recipe_id, ingredient_id) 15 | ); 16 | ``` 17 | 18 | We set this to error because we should clear out connections before we let developers delete recipes or ingredients. We don't want to cascade deletes because that could delete recipes and ingredients unintentionally and we don't want to set to null because then we'd have a bunch of half-null connections left over. 19 | 20 | We're going over constraints in the next chapter but we're basically saying "the combination of recipe_id and ingredient_id must be unique" and we're setting that as the primary key instead of an incrementing ID. 21 | 22 | This table will describe the many-to-many relationship with two foreign keys between ingredients and recipes. Now we can insert records into here that describe how an ingredient belongs to a recipe. 23 | 24 | ```sql 25 | INSERT INTO recipe_ingredients 26 | (recipe_id, ingredient_id) 27 | VALUES 28 | (1, 10), 29 | (1, 11), 30 | (1, 13), 31 | (2, 5), 32 | (2, 13); 33 | ``` 34 | 35 | > These recipes are nonsensical, don't expect them to be correct recipes. 36 | 37 | This is the way to connect multiple columns together. You have two tables that represent the two distinct concepts (ingredients and recipes) and then you use another table to describe the relationships between them. 38 | 39 | So now we have two recipes that have ingredients, cookies and empanadas. Since we inserted the cookies first, it will very likely have the id of 1 (unless you have inserted some other things.) Let's select those records. 40 | 41 | ```sql 42 | SELECT 43 | i.title AS ingredient_title, 44 | i.image AS ingredient_image, 45 | i.type AS ingredient_type 46 | FROM 47 | recipe_ingredients ri 48 | INNER JOIN 49 | ingredients i 50 | ON 51 | i.id = ri.ingredient_id 52 | WHERE 53 | ri.recipe_id = 1; 54 | ``` 55 | 56 | Inner join is what we're looking for because we are only looking for rows that have connections on both sides. 57 | 58 | Now let's use another INNER JOIN to add in the recipes. 59 | 60 | ```sql 61 | SELECT 62 | i.title AS ingredient_title, 63 | i.image AS ingredient_image, 64 | i.type AS ingredient_type, 65 | r.title AS recipe_title, 66 | r.body AS recipe_body, 67 | r.recipe_id AS rid, 68 | i.id AS iid 69 | FROM 70 | recipe_ingredients ri 71 | INNER JOIN 72 | ingredients i 73 | ON 74 | i.id = ri.ingredient_id 75 | INNER JOIN 76 | recipes r 77 | ON 78 | r.recipe_id = ri.recipe_id; 79 | ``` 80 | -------------------------------------------------------------------------------- /lessons/04-joins-and-constraints/E-constraints.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | You actually already know what constraints are and have used them not once but TWICE so far! 6 | 7 | A constraint is just a constraint you put around a column. For example, NOT NULL is a constraint we've used for our primary keys. You always need a primary key. 8 | 9 | UNIQUE is another constraint that dictates that this column must be unique amongst all other columns. Ever wonder how an app can tell you so quickly if an email or a user name is taken so quickly? They likely use a UNIQUE constraint on those two columns. They should. PRIMARY is another such constraint. 10 | 11 | We also added a constraint on recipe_ingredients that every row must have a unique combination of recipe_id and ingredient_id. We could have added an additional primary, incrementing, unique ID but what would we use that for? To me at this moment it doesn't serve a purpose so we can just use the other two as a unique key. This is common for these sorts of many-to-many connecting tables. 12 | 13 | Foreign keys are a type of constraint as well. They enforce consistency amongst tables. 14 | 15 | ## CHECK 16 | 17 | CHECK allows you to set conditions on the data. You can use it for enforcing enumerated types (e.g. 'male', 'female', 'nonbinary', 'other' for gender.) Or you could have it test that a zipcode is five characters (in the USA.) Or that an age isn't a negative number. 18 | 19 | We actually have a very good use case for one: our type in the ingredients. It can be 'fruit', 'vegetable', 'meat', or 'other'. We could write a CHECK constraint that enforces that. 20 | 21 | ```sql 22 | ALTER TABLE ingredients 23 | ADD CONSTRAINT type_enums 24 | CHECK 25 | (type IN ('meat','fruit','vegetable','other')); 26 | ``` 27 | 28 | This will add a constraint to the existing table. You can also create these constraints when you create the table (like we did with the foreign keys as well as unique, not null, and primary.) 29 | 30 | Just for fun, let's try to break it. 31 | 32 | ```sql 33 | INSERT INTO 34 | ingredients 35 | (title, image, type) 36 | VALUES 37 | ('lol', 'wat.svg', 'obviously not a type'); 38 | ``` 39 | -------------------------------------------------------------------------------- /lessons/04-joins-and-constraints/F-project.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | Your next project is the recipes side of the page. Here you will have a list of recipes, and if a user clicks on one of the recipes, they will be taken to the recipe page where they will see the recipe, the description, all the photos, and all the ingredients. 6 | 7 | You'll need to repopulate your `recipes_photos` table before beginning the project 8 | 9 | ```sql 10 | INSERT INTO recipes_photos 11 | (recipe_id, url) 12 | VALUES 13 | (1, 'cookies1.jpg'), 14 | (1, 'cookies2.jpg'), 15 | (1, 'cookies3.jpg'), 16 | (1, 'cookies4.jpg'), 17 | (1, 'cookies5.jpg'), 18 | (2, 'empanada1.jpg'), 19 | (2, 'empanada2.jpg'), 20 | (3, 'jollof1.jpg'), 21 | (4, 'shakshuka1.jpg'), 22 | (4, 'shakshuka2.jpg'), 23 | (4, 'shakshuka3.jpg'), 24 | (5, 'khachapuri1.jpg'), 25 | (5, 'khachapuri2.jpg'); 26 | -- no pictures of xiao long bao 27 | ``` 28 | 29 | Alternatively, here's a [big SQL query which a bunch of recipes.][recipe] This will tear down all your tables and recreate them! This is helpful if you need to restart but be aware if you've added things this will delete them. 30 | 31 | Here's a hint on selecting just the first image for all the recipe photos. 32 | 33 | ```sql 34 | SELECT DISTINCT ON (r.recipe_id) 35 | * 36 | FROM 37 | recipes r 38 | LEFT JOIN 39 | recipes_photos rp 40 | ON 41 | r.recipe_id = rp.recipe_id; 42 | ``` 43 | 44 | That distinct ensures that we only have one recipe per image and not a bunch of rows of images and rows. The distinct bit means that's only one `recipe.recipe_id` is allowed in this results set. If we left part out, we'd get all the photos back which we don't need for the first page. 45 | 46 | For the second page, you will likely need to query the database twice. That's just fine. Only have one query where it makes sense. 47 | 48 | Again, you will only be editing recipes/api.js. You _can_ edit other things if you want to but it shouldn't be necessary. 49 | 50 | Alright, good luck! Tweet me your results! 51 | 52 | [recipe]: /recipes.sql 53 | -------------------------------------------------------------------------------- /lessons/04-joins-and-constraints/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "code-merge" 3 | } -------------------------------------------------------------------------------- /lessons/05-jsonb/A-unstructured-data.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | Have you heard of [MongoDB][mongodb]? Scott Moss has a great course on it on Frontend Masters. It's a document-oriented database (as opposed to a relational database like PostgreSQL.) It really excels at data that is _unstructured_. Unstructured data means it's really hard to say ahead of time what your data is going to look like. It can be things like "I don't know how my data schema is going to evolve" to "I don't know how many things is going to have." 6 | 7 | Take our photos table for example. We don't know how many photos each recipe is going to have. They each could have 0 photos or 30 photos. Both of those are acceptable and correct. MongoDB used to be the only\* (yes, others did it, but MongoDB was the most used) way to handle that well. 8 | 9 | Then Postgres introduced the JSON (and then subsequently the JSONB) data type that allows PostgreSQL to handle these unbounded schemas, similar to how MongoDB does. Essentially the JSONB datatype lets you stick a JSON object into a column and then later we can use specific language to query that object. 10 | 11 | ## hstore 12 | 13 | A little note on hstore, a data type that we won't be covering today. It's a key value store (think memcache or Redis) data type for PostgreSQL. These days it's far less useful because you can get basically all the functionality from JSONB. 14 | 15 | ## JSON vs JSONB 16 | 17 | For almost everything we're going to show here you could use either. If you want a one liner: always use JSONB, in almost every case it's better. The JSON datatype is a text field that validates it's valid JSON, and that's it. It doesn't do any compression, doesn't validate any of the interior values datatypes, and a few other benefits. The B in JSONB literally stands for "better". 18 | 19 | [Here's a great blog post on it from Citus Data][citus]. Heres's a quote on what they recommend: 20 | 21 | > - JSONB - In most cases 22 | > - JSON - If you’re just processing logs, don’t often need to query, and use as more of an audit trail 23 | > - hstore - Can work fine for text based key-value looks, but in general JSONB can still work great here 24 | 25 | [mongodb]: https://frontendmasters.com/courses/mongodb/ 26 | [citus]: https://www.citusdata.com/blog/2016/07/14/choosing-nosql-hstore-json-jsonb/ 27 | -------------------------------------------------------------------------------- /lessons/05-jsonb/B-jsonb.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | title: "JSONB" 4 | --- 5 | 6 | Let's add a JSONB field to our recipes. We'll call it `meta` as in metadata but we could call it anything. And keep in mind this is just a normal JSON object. You can have arrays, nested objects, whatever. 7 | 8 | ```sql 9 | ALTER TABLE recipes 10 | ADD COLUMN meta JSONB; 11 | ``` 12 | 13 | That's easy enough. Okay, let's now add a few rows to it. Keep in mind that it does have to be valid JSON or Postgres won't let you add it. 14 | 15 | ```sql 16 | UPDATE 17 | recipes 18 | SET 19 | meta='{ "tags": ["chocolate", "dessert", "cake"] }' 20 | WHERE 21 | recipe_id=16; 22 | 23 | UPDATE 24 | recipes 25 | SET 26 | meta='{ "tags": ["dessert", "cake"] }' 27 | WHERE 28 | recipe_id=20; 29 | 30 | UPDATE 31 | recipes 32 | SET 33 | meta='{ "tags": ["dessert", "fruit"] }' 34 | WHERE 35 | recipe_id=45; 36 | 37 | UPDATE 38 | recipes 39 | SET 40 | meta='{ "tags": ["dessert", "fruit"] }' 41 | WHERE 42 | recipe_id=47; 43 | ``` 44 | 45 | Okay, let's select all tags from any recipe that has meta data. 46 | 47 | ```sql 48 | SELECT meta -> 'tags' FROM recipes WHERE meta IS NOT NULL; 49 | ``` 50 | 51 | The `->` is an accessor. In JS terms, this is like saying `meta.tags`. If we were accessing another layer of nesting, you just use more `->`. Ex. `SELECT meta -> 'name' -> 'first'`. 52 | 53 | Let's try selecting just the first element of each one using that. 54 | 55 | ```sql 56 | SELECT meta -> 'tags' -> 0 FROM recipes WHERE meta IS NOT NULL; 57 | ``` 58 | 59 | This should give you back only the first item of each. Notice they're still weirdly in `""`. This is because the `->` selector gives you back a JSON object so you can keep accessing it. If you want it back as just text, you use `->>`. 60 | 61 | ```sql 62 | SELECT meta -> 'tags' ->> 0 FROM recipes WHERE meta IS NOT NULL; 63 | ``` 64 | 65 | Notice the first one `->` because we need tags back as a JSON object (because accessors don't work on text) but we use `->>` on the second one so we can get the text back. Not always important but it bit me a few times and I wanted to help you. 66 | 67 | Alright, let's select all the cakes. 68 | 69 | ```sql 70 | SELECT recipe_id, title, meta -> 'tags' FROM recipes WHERE meta -> 'tags' ? 'cake'; 71 | SELECT recipe_id, title, meta -> 'tags' FROM recipes WHERE meta -> 'tags' @> '"cake"'; 72 | ``` 73 | 74 | Both of these work. The first one with the `?` checks to see if 'cake' is a top level key available in which case it is. 75 | 76 | The second with the `@>` is doing a "does this array contains this value. That's why we do need the extra double quotes, since it is a string in a JSON value. 77 | -------------------------------------------------------------------------------- /lessons/05-jsonb/C-when-to-use.md: -------------------------------------------------------------------------------- 1 | I use JSONB a lot of PostgreSQL. I have a long history of using MongoDB and therefore my mindset fits well with the document oriented structures of it. JSONB does an amazing job of slotting into that in the right places. 2 | 3 | For any record that is going to be present and finite, I always elect to make it a normal column. Then if I have any 1-to-many relationships like we did with photos or tags, I'll use JSONB. Because one photo only belongs to one recipe, this would have been perfect to model as a JSONB field. 4 | 5 | Tags are a bit more murky: in theory we _are_ sharing tags across recipes (implying a many-to-many relationship like ingredients). If I decide to rename the "desserts" tag to "sweets", I'd have to go update that on every JSONB field that used that. Better yet here, we could have a table of tags that IDs and names, and then the JSONB field of every recipe could refer to IDs. Best of both worlds. Great way to model many-to-many relationships without the third table. 6 | 7 | So why did we spend all this time learning how to model data "the old school" way with many tables? 8 | 9 | 1. Much of the data you'll encounter in your job is still modeled this way. 10 | 1. I'm not a performance expert but JOINs and JSONB queries have different performance profiles. It's good to know both in case you do need to model your data one way or the other. 11 | 1. I wanted to teach you JOINs 12 | 13 | In general I make ample use of JSONB because it closely mirrors how a programmer thinks about problems. Just keep in mind you can get better performance almost always as a normal column. Use columns always and then use JSONB when you have unstructured or unbounded data. 14 | -------------------------------------------------------------------------------- /lessons/05-jsonb/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "JSONB", 3 | "icon": "code" 4 | } -------------------------------------------------------------------------------- /lessons/06-aggregation/A-aggregation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | Occasionally you need to query for macro statistics about your tables, not just query for individual rows. 6 | 7 | Let's use that we've already used before, `COUNT`. What if we want to know how many ingredients we have overall in our ingredients table? 8 | 9 | ```sql 10 | SELECT COUNT(*) FROM ingredients; 11 | ``` 12 | 13 | `COUNT` is an aggregation function. We give the `*` is saying "count everything and don't remove nulls or duplicates of any variety". 14 | 15 | What if we wanted to count how many distinct `type`s of ingredients we have in the ingredients table? 16 | 17 | ```sql 18 | SELECT COUNT(DISTINCT type) FROM ingredients; 19 | ``` 20 | 21 | This is going to tell how many different `type`s we have in the ingredients table. Keep in mind the query to see _what_ the distinct ingredients is 22 | 23 | ```sql 24 | SELECT DISTINCT type FROM ingredients; 25 | ``` 26 | 27 | The first query gives you the number, the count of many distinct things are in the list. The second query gives you what those distinct things are with no indication of how many of each there are. There could be 1 fruit and 10,000 vegetables and you'd have no indication of that. 28 | 29 | Okay, so you want to see both at the same time? Let's see that. 30 | 31 | ```sql 32 | SELECT 33 | type, COUNT(type) 34 | FROM 35 | ingredients 36 | GROUP BY 37 | type; 38 | ``` 39 | 40 | This is combining both of what we saw plus a new thing, `GROUP BY`. This allows us to specify what things we want to aggregate together: the type. Keep in mind if you want to SELECT for something with a GROUP BY clause, you do need to put them in the GROUP BY clause. 41 | 42 | > The following query intentionally doesn't work 43 | 44 | ```sql 45 | SELECT 46 | title, type, COUNT(type) 47 | FROM 48 | ingredients 49 | GROUP BY 50 | type; 51 | ``` 52 | 53 | This query doesn't work because title isn't in the GROUP BY clause. If you do add it, then you're saying "group by unique title + type combinations" which in this case everything is unique. I call this out because people don't realize in order to use SELECT and GROUP BY together, you have to put everything you want to SELECT in the GROUP BY. 54 | -------------------------------------------------------------------------------- /lessons/06-aggregation/B-having.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "HAVING" 3 | --- 4 | 5 | Okay, so now you want to select only INGREDIENTS with less than 10 items in your database so you can know what sorts of things you need to add to your database. 6 | 7 | The temptation here would be to use `WHERE` 8 | 9 | > The following query intentionally doesn't work 10 | 11 | ```sql 12 | SELECT 13 | type, COUNT(type) 14 | FROM 15 | ingredients 16 | WHERE 17 | COUNT(count) <= 10 18 | GROUP BY 19 | type; 20 | ``` 21 | 22 | You'll get the error that `count` doesn't exist and it's because `count` isn't something you're selecting for, it's something you're aggregating. The `where` clause filters on the rows you're selecting which happens _before_ the aggregation. This can be useful because let's say we wanted to select only things that have an `id` higher than 30. 23 | 24 | ```sql 25 | SELECT 26 | type, COUNT(type) 27 | FROM 28 | ingredients 29 | WHERE 30 | id > 30 31 | GROUP BY 32 | type; 33 | ``` 34 | 35 | Okay, so how we do filter based on the aggregates and not on the rows themselves? With `HAVING`. 36 | 37 | ```sql 38 | SELECT 39 | type, COUNT(type) 40 | FROM 41 | ingredients 42 | GROUP BY 43 | type 44 | HAVING 45 | COUNT(type) < 10; 46 | ``` 47 | 48 | And keep in mind you can use both together 49 | 50 | ```sql 51 | SELECT 52 | type, COUNT(type) 53 | FROM 54 | ingredients 55 | WHERE 56 | id > 30 57 | GROUP BY 58 | type 59 | HAVING 60 | COUNT(type) < 10; 61 | ``` 62 | 63 | There are more aggregation functions like `MIN` (give the smallest value in this selected set), `MAX` (same but max), and `AVG` (give me the average). We'll use those in the next exercise with the movie data set. 64 | -------------------------------------------------------------------------------- /lessons/06-aggregation/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "square-plus" 3 | } -------------------------------------------------------------------------------- /lessons/07-functions-triggers-and-procedures/A-functions.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | What should you do if you find you referring to the same query over and over again? We have several shortcuts to store frequently used functions in PostgreSQL. In the next chapter we'll talk about views which is one way to do it but we are going to talk about functions and procedures in this chapter. Let's learn about functions in this section and in the next section when you learn about procedures we will talk about the difference is between the two. 6 | 7 | A function is just like you expect from a programming lanugage. Given parameters, it will return an output of some variety. 8 | 9 | Let's say we're focusing our recipe site on recipes with few ingredients (for lazy people, like me.) Let's say we give users a way to filter recipes by how many ingredients are in the recipe. 10 | 11 | ```sql 12 | SELECT 13 | r.title 14 | FROM 15 | recipe_ingredients ri 16 | 17 | INNER JOIN 18 | recipes r 19 | ON 20 | r.recipe_id = ri.recipe_id 21 | 22 | GROUP BY 23 | r.title 24 | HAVING 25 | COUNT(r.title) BETWEEN 4 AND 6; 26 | ``` 27 | 28 | This will give you all recipes that are have 4, 5, or 6 ingredients in them. Okay, let's saying this is a big focus of our website and we're going to repeat this everywhere, it would be annoying to copy and paste this everywhere. Wouldn't be cool if PostgreSQL would let us wrap this up into an easy to use function? 29 | 30 | Ask and you shall receive. 31 | 32 | ```sql 33 | CREATE OR REPLACE FUNCTION 34 | get_recipes_with_ingredients(low INT, high INT) 35 | RETURNS 36 | SETOF VARCHAR 37 | LANGUAGE 38 | plpgsql 39 | AS 40 | $$ 41 | BEGIN 42 | RETURN QUERY SELECT 43 | r.title 44 | FROM 45 | recipe_ingredients ri 46 | 47 | INNER JOIN 48 | recipes r 49 | ON 50 | r.recipe_id = ri.recipe_id 51 | 52 | GROUP BY 53 | r.title 54 | HAVING 55 | COUNT(r.title) between low and high; 56 | END; 57 | $$; 58 | ``` 59 | 60 | > You could accomplish this several ways. Functions is just an easy to do this. 61 | 62 | Now query it. 63 | 64 | ```sql 65 | SELECT * FROM get_recipes_with_ingredients(2, 3); 66 | ``` 67 | 68 | Now we have a function that we can call from whenever we need to our more complicated query. This can come in very handy if your team has shared needs. 69 | 70 | Let's break down some features of it: 71 | 72 | - You can just say CREATE or you can just say REPLACE. I added both so it wouldn't trip you up 73 | - `DROP FUNCTION get_recipes_with_ingredients(low int, high int)` will delete the function 74 | - You do need to declare a return type, either in the function definiton like we did, or in the invocation 75 | - `$$` is called "dollar quotes". You can actually use them instead of `'` in your insertions if you're sick of writing `''` for your single quotes and `\\` for your backslashes. It's just really ugly. Here we use them because we have a long quote and it would be very annoying to try and escape everything 76 | - This language is called PL/pgSQL. It's a very SQL like language designed to be easy to write functions and procedures. PostgreSQL actually allows itself to be extended and you can use [JavaScript][js], [Python][py], and other languages to write these as well 77 | - Here we're just returning the result of a query. There's a myriad of more powerful things you can do with functions and fun sorts of aggregation. I'll leave that to you to explore. This is not a class on functions; it's a very deep topic 78 | 79 | A note from your potential future self or coworker: _do not go overboard_. This are powerful but they can also be weird to debug. Where do you store the code for these? How are you going to debug it when it has a bug in production? There are ways to handle these things but it's _different_ than just handling it in your server's code. Use with caution. 80 | 81 | [js]: https://plv8.github.io/ 82 | [py]: https://www.postgresql.org/docs/current/plpython.html 83 | -------------------------------------------------------------------------------- /lessons/07-functions-triggers-and-procedures/B-procedures.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | A different-but-similar idea to functions is procedures. They're really similar and have a lot of overlap but have some slight differences. 6 | 7 | Functions can do more. Almost everything that procedures can do functions can also do whereas vice versa is not true. 8 | 9 | A procedure is designed to do an action of some variety. It's explictly _not_ for returning data (and cannot return data) whereas a function can return data (but does not have to.) 10 | 11 | Let's make an example with our recipes database. Let's say we have some churn on our ingredients where images can get created and deleted and sometimes images are set to null. Let's say we wanted to _tolerate_ that (as in we don't want to make it so there's a constraint on the image column on NOT NULL) but we periodically want to go update that column to be default.jpg if there's nothing set. First, let's select everything has null for an image. 12 | 13 | ```sql 14 | SELECT * FROM ingredients WHERE image IS NULL; 15 | ``` 16 | 17 | Nothing right now (if you've been following along with me.) So let's insert another real quick. 18 | 19 | ```sql 20 | INSERT INTO 21 | ingredients 22 | (title, type) 23 | VALUES 24 | ('venison', 'meat'); 25 | ``` 26 | 27 | Okay, let's try our query again. 28 | 29 | ```sql 30 | SELECT * FROM ingredients WHERE image IS NULL; 31 | ``` 32 | 33 | We could write an update that we run via cron job that just called `UPDATE` but let's write a procedure that does it. 34 | 35 | ```sql 36 | CREATE PROCEDURE 37 | set_null_ingredient_images_to_default() 38 | LANGUAGE 39 | SQL 40 | AS 41 | $$ 42 | UPDATE 43 | ingredients 44 | SET 45 | image = 'default.jpg' 46 | WHERE 47 | image IS NULL; 48 | $$; 49 | ``` 50 | 51 | - Notice we did not use the `plpgsql` language but the `SQL` language. Because this is a simple query without any other bells or whistles, we can use straight SQL as the language. If we needed conditionals, loops, or any other programming "stuff" we'd need something like plpgsql, JavaScript, Python, etc. 52 | - Other than that, this looks a lot like a function with no return type. It does its action and it is done. 53 | 54 | Okay, let's invoke this procedure now. 55 | 56 | ```sql 57 | CALL set_null_ingredient_images_to_default(); 58 | SELECT * FROM ingredients WHERE image IS NULL; 59 | ``` 60 | 61 | You use `CALL` instead of SELECT to invoke procedures. Now we can invoke this procedure whenever we need to periodically make some maintenance to our images. 62 | 63 | We'll expand on a great way to use procedures in the next lesson with triggers. 64 | -------------------------------------------------------------------------------- /lessons/07-functions-triggers-and-procedures/C-triggers.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | Let's talk about triggers. Let's say you have a function that you want to run reactively to some event happening in your database. This is what a trigger is for. 6 | 7 | For example, let's say we need an audit trail of the names of recipes. Maybe we have a bug we're tracking down or it's important for us to be able to say "this recipe used to be called this but we've since edited it." A trigger is perfect for this. 8 | 9 | Quickly let's make a table that will serve as our repository for this data. 10 | 11 | ```sql 12 | CREATE TABLE updated_recipes ( 13 | id INT GENERATED ALWAYS AS IDENTITY, 14 | recipe_id INT, 15 | old_title VARCHAR (255), 16 | new_title VARCHAR (255), 17 | time_of_update TIMESTAMP 18 | ); 19 | ``` 20 | 21 | The `TIMESTAMP` data type is new to you but this is just a standard way of storing a time in PostgreSQL. Try `SELECT NOW()` to see what one looks like. 22 | 23 | ```sql 24 | CREATE OR REPLACE FUNCTION log_updated_recipe_name() 25 | RETURNS 26 | TRIGGER 27 | LANGUAGE 28 | plpgsql 29 | AS 30 | $$ 31 | BEGIN 32 | IF OLD.title <> NEW.title THEN 33 | INSERT INTO 34 | updated_recipes (recipe_id, old_title, new_title, time_of_update) 35 | VALUES 36 | (NEW.recipe_id, OLD.title, NEW.title, NOW()); 37 | END IF; 38 | RETURN NEW; 39 | END; 40 | $$; 41 | ``` 42 | 43 | Another function here but with a bit more logic. Here we're asking if the title changed between `OLD` (what was there before) and `NEW` (what is there now). If `body` updated this trigger wouldn't do anything because it only checks title. If that conditional is true we run an insert statement and then we end. 44 | 45 | Make sure you have that RETURN NEW. Functions have to return what the SELECT would expect to get back. In this case we're just giving back NEW. You can have a trigger intercept and modify inserts, updates, etc. 46 | 47 | Keep in mind this just creates a function but doesn't yet bind it to an event. We need ot create a trigger that runs our function. 48 | 49 | Let's set up our trigger. 50 | 51 | ```sql 52 | CREATE OR REPLACE TRIGGER updated_recipe_trigger 53 | AFTER UPDATE ON recipes 54 | FOR EACH ROW EXECUTE PROCEDURE log_updated_recipe_name(); 55 | ``` 56 | 57 | This takes our function that we created and binds it to the update events on recipes. You can do this on inserts, deletes, and all sorts of other events. We're also running this _after_ the insert happens. If you wanted to prevent certain updates or modify them, you could run this before as well. 58 | 59 | This is a rudimentary intro to triggers but there's a lot more you can do here! This is just one thing. 60 | 61 | > Note you can't run _procedures_ as triggers. Triggers always deal with functions. However there's nothing preventing you from `CALL`ing a procedure from a function. 62 | -------------------------------------------------------------------------------- /lessons/07-functions-triggers-and-procedures/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "stairs" 3 | } -------------------------------------------------------------------------------- /lessons/08-the-movie-database/A-exercises.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | Let's move on from our recipe database that we crafted. While before I was showing you how to set up schemas and how to write to a database, the next sections are going to be more focused on reading from a large dataset. 6 | 7 | The database I've prepared for us to work on is [the Open Media Database][omdb]. This is a wonderful project to catalog movies into a useful database. As part of this they offer a [database dump][gh-omdb] that is explictly created for folks to learn PostgreSQL with. 8 | 9 | I went ahead wrapped the above into a public container and [put it on Docker Hub][docker]. It's a pretty simple container, [see here to see the Dockerfile][gh-docker]. 10 | 11 | Let's run it. Run the following in your CLI. 12 | 13 | ```bash 14 | docker run -e POSTGRES_PASSWORD=lol --name=sql -d -p 5432:5432 --rm btholt/complete-intro-to-sql 15 | ``` 16 | 17 | > You need to stop the recipe container for the above command to work since both will try to bind to port 5432. If you want both to run, you can do `5433:5432` instead of `5432:5432` to expose port 5433 on your local machine so there won't be a conflict. 18 | 19 | Okay, now to connect to that container in psql, do: 20 | 21 | ```bash 22 | docker exec -u postgres -it sql psql omdb 23 | ``` 24 | 25 | > The last omdb connects you directly to the omdb database instead of having to run `\c omdb` when you first connect 26 | 27 | Okay, let's explore a bit. 28 | 29 | - Run `\l` to see all database. 30 | - Run `\d` to see all tables. 31 | 32 | We can see there are a ton of tables which means there are a lot of relations going on. 33 | 34 | Let's look at one of them. Run `\d movies` to see everything about movies. You can see all the columns and indexes (we're about to see more about indexes here in a bit.) 35 | 36 | Before we start learning some more concepts, let's have some fun doing some queries: 37 | 38 | ## Which movie made the most money? 39 | 40 | - Some movies have null revenues. Use `COALESCE(column_name, 0)` to make nulls into 0s. 41 | - Won't need any joins here. 42 | - `ORDER BY` is your friend, as is `DESC` 43 | 44 | ## How much revenue did the movies Keanu Reeves act in make? 45 | 46 | - `COALESCE` will be useful again 47 | - `casts` holds the links between `movies` and `people` 48 | - Multiple joins necessary 49 | 50 | ## Which 5 people were in the movies that had the most revenue? 51 | 52 | - Multiple joins here too 53 | 54 | ## Which 10 movies have the most keywords? 55 | 56 | - `movie_keywords` contains the connections 57 | - `categories` contains the names of the keywords 58 | - Multiple joins necessary 59 | - This database contains multiple languages of keywords. Don't worry about it. 60 | 61 | ## Which category is associated with the most movies 62 | 63 | - Similar to the above query, just starting from a different place. 64 | 65 | [omdb]: https://www.omdb.org 66 | [gh-omdb]: https://github.com/credativ/omdb-postgresql 67 | [docker]: https://hub.docker.com/r/btholt/complete-intro-to-sql 68 | [gh-docker]: https://github.com/btholt/complete-intro-to-sql-container/blob/main/Dockerfile 69 | -------------------------------------------------------------------------------- /lessons/08-the-movie-database/B-answers.md: -------------------------------------------------------------------------------- 1 | ## Which movie made the most money? 2 | 3 | ```sql 4 | SELECT 5 | name, revenue 6 | FROM 7 | movies 8 | ORDER BY 9 | COALESCE(revenue, 0) DESC 10 | LIMIT 5; 11 | ``` 12 | 13 | ## How much revenue did the movies Keanu Reeves act in make? 14 | 15 | ```sql 16 | SELECT 17 | COALESCE(SUM(m.revenue),0) AS total 18 | FROM 19 | movies m 20 | 21 | INNER JOIN 22 | casts c 23 | ON 24 | m.id = c.movie_id 25 | 26 | INNER JOIN 27 | people p 28 | ON 29 | c.person_id = p.id 30 | 31 | WHERE 32 | p.name = 'Keanu Reeves'; 33 | ``` 34 | 35 | ## Which 5 people were in the movies that had the most revenue? 36 | 37 | ```sql 38 | SELECT 39 | p.name, COALESCE(SUM(m.revenue),0) AS total 40 | FROM 41 | movies m 42 | 43 | INNER JOIN 44 | casts c 45 | ON 46 | m.id = c.movie_id 47 | 48 | INNER JOIN 49 | people p 50 | ON 51 | c.person_id = p.id 52 | 53 | GROUP BY 54 | p.id, p.name 55 | 56 | ORDER BY 57 | total DESC 58 | 59 | LIMIT 5; 60 | ``` 61 | 62 | ## Which 10 movies have the most keywords? 63 | 64 | ```sql 65 | SELECT 66 | m.name, COUNT(c.id) AS count 67 | FROM 68 | movies m 69 | 70 | INNER JOIN 71 | movie_keywords mk 72 | ON 73 | mk.movie_id = m.id 74 | 75 | INNER JOIN 76 | categories c 77 | ON 78 | mk.category_id = c.id 79 | 80 | GROUP BY 81 | m.id, m.name 82 | 83 | ORDER BY 84 | count DESC 85 | 86 | LIMIT 10; 87 | ``` 88 | 89 | ## Which category is associated with the most movies 90 | 91 | ```sql 92 | SELECT 93 | c.name, COUNT(mk.category_id) AS count 94 | FROM 95 | movie_keywords mk 96 | 97 | INNER JOIN 98 | categories c 99 | ON 100 | c.id = mk.category_id 101 | 102 | GROUP BY 103 | c.name, mk.category_id 104 | 105 | ORDER BY 106 | count DESC 107 | 108 | LIMIT 5; 109 | ``` 110 | -------------------------------------------------------------------------------- /lessons/08-the-movie-database/C-pgadmin.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: pgAdmin 3 | --- 4 | 5 | I'm going to show you a quick tool that you can continue using through out the rest of the course or you can just know it's there and stop using it. Some people and need and love it, some people (like me) got used to using the command line (psql) and don't use it as much. 6 | 7 | I speak of [pgAdmin][pgadmin]. Very similar to [phpMyAdmin][phpmyadmin] for MySQL, it exposes a GUI to interact with a PostgreSQL database. Again, I'm not super familiar with its workings but I can help you get it running and you can toy with it a bit. 8 | 9 | In your app that you cloned, there is [a Docker Compose file][compose] that you can use to get PostgreSQL spun up with pgAdmin. 10 | 11 | In the root directory of your sql-apps directory, run the following: 12 | 13 | > Note that the following will download the pgAdmin container, about ~400MB in size. 14 | 15 | > You also may need to `docker kill sql` since they'll both try to listen on port `5432`. 16 | 17 | ```bash 18 | docker compose up 19 | ``` 20 | 21 | After you see the PostgreSQL database start up then the pgAdmin container start up, try going to [localhost:1234](http://localhost:1234). From there log in with `admin@example.com` and `example`. 22 | 23 | Once in the menu, follow these instructions: 24 | 25 | Click "Add New Server". 26 | 27 | ![Add new server](/images/add-new-server.png) 28 | 29 | Add a name. You can call it whatever you want, it's just internal to pgAdmin. I called mine db. After that, click connection. 30 | 31 | ![Add a name then click connection](/images/connection.png) 32 | 33 | Here you need to 34 | 35 | - Put `db` ad the hostname/address (as this is what Docker Compose is exposing the host as to the pgAdmin container) 36 | - Put port as `5432` 37 | - Leave maintenance database as `postgres` 38 | - Put username as `postgres` 39 | - Put password as `lol` 40 | - Save password 41 | - Click save 42 | 43 | ![Config of adding a server in pgAdmin](/images/config.png) 44 | 45 | > If save is grayed out, you likely didn't add a name on the General tab. 46 | 47 | From here you can click around the pgAdmin page and see what you can do. Maybe start by writing some queries by using the Tools > Query Tool. 48 | 49 | [pgadmin]: https://www.pgadmin.org/ 50 | [phpmyadmin]: https://www.phpmyadmin.net/ 51 | [compose]: https://github.com/btholt/sql-apps/blob/main/docker-compose.yml 52 | -------------------------------------------------------------------------------- /lessons/08-the-movie-database/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "clapperboard" 3 | } -------------------------------------------------------------------------------- /lessons/09-query-performance/A-explain.md: -------------------------------------------------------------------------------- 1 | Let's have a chat about query performance. 2 | 3 | How different do you think these queries are? 4 | 5 | ```sql 6 | SELECT * FROM movies WHERE name='Tron Legacy'; 7 | SELECT * FROM movies WHERE id=21103; 8 | ``` 9 | 10 | Turns out, from a performance perspective, they are _wildly_ different. The second query is _623×_ times faster on my computer. Why? Let's find out! 11 | 12 | ```sql 13 | EXPLAIN ANALYZE SELECT * FROM movies WHERE name='Tron Legacy'; 14 | EXPLAIN ANALYZE SELECT * FROM movies WHERE id=21103; 15 | ``` 16 | 17 | This will show you some output on the performance profile of our query. 18 | 19 | A couple key things I want to draw to your attention: 20 | 21 | - `cost=0.00..4988.77` vs `cost=0.42..8.44`. This cost is arbitrary unit meant to be used comparatively, like in this case. 22 | - The first number is the startup cost. Generally for SELECTs like this it's near zero but if you're doing a sort of some variety you can incur startup cost. 23 | - The second number is the total cost of the query. You can see here that they are _very_ different. 24 | 25 | Here's a quote on how they measure the cost [from a great article][cost] 26 | 27 | > The cost units are anchored (by default) to a single sequential page read costing 1.0 units (seq_page_cost). Each row processed adds 0.01 (cpu_tuple_cost), and each non-sequential page read adds 4.0 (random_page_cost). There are many more constants like this, all of which are configurable. That last one is a particularly common candidate, at least on modern hardware. We’ll look into that more in a bit. 28 | 29 | - `Seq Scan` vs `Index Scan` is what's actually making a difference here. 30 | - `Seq Scan` means it read _every row in the table_ to get your ansewr. Yeah, not good. You can see it read `Rows Removed by Filter: 175720` to get our one answer. Wild! 31 | - `Index Scan` means it had access to an index to find the correct row. Remember [trees from data structures and algorithms][tree]? This is where they are used!! We can tell PostgreSQL in advance "hey, this is going to get queried in the future and I want to it to be fast, please build a tree so that you can find it faster. We'll talk about making new indexes in the next section. 32 | 33 | ## More complicated queries. 34 | 35 | Let's grab one of our previous lesson's answers. 36 | 37 | ```sql 38 | EXPLAIN ANALYZE SELECT 39 | m.name, COUNT(c.id) AS count 40 | FROM 41 | movies m 42 | 43 | INNER JOIN 44 | movie_keywords mk 45 | ON 46 | mk.movie_id = m.id 47 | 48 | INNER JOIN 49 | categories c 50 | ON 51 | mk.category_id = c.id 52 | 53 | GROUP BY 54 | m.id, m.name 55 | 56 | ORDER BY 57 | count DESC 58 | 59 | LIMIT 10; 60 | ``` 61 | 62 | Here you'll see a variety new bits of nested information. Let's analyze (lol) 63 | 64 | - Startup cost is almost everything here. That means it's taking a long time to get all the rows in a place to be queried. Once all the rows are in assembled and joined together the actual execution is trivial. 65 | - We aggregated `rows=46516` in the second step into our final answer 66 | - Our merge join was actually pretty fast because we did it on IDs that had indexes on them. 67 | - The aggregation and the ordering took the most time here. That's the take away. 68 | 69 | [cost]: https://scalegrid.io/blog/postgres-explain-cost/ 70 | [tree]: https://btholt.github.io/complete-intro-to-computer-science/binary-search-tree 71 | -------------------------------------------------------------------------------- /lessons/09-query-performance/B-indexes.md: -------------------------------------------------------------------------------- 1 | ## Identifying what to index 2 | 3 | We've identified some slow queries and how to diagnose them as slow. We've identified what a index scan is versus what a sequential scan is. Here we're going to learn how to index things and when we want to do that. 4 | 5 | First thing is, _don't index everything_. Indexes take space and do incur some overhead for PostgreSQL to decide if it's going to use (using what's called the query planner.) If you have data that's rarely accessed or frequently filtered out then don't index it! 6 | 7 | So what do you index? Things you query frequently that have to filter out lots of rows. 8 | 9 | ## It's okay that sometimes that the planner doesn't use the index 10 | 11 | Another thing to keep in mind is indexes are only useful when you're filtering data out or doing some sort of different sort. It all depends on what you want to do. 12 | 13 | Think of it like a dictionary and the page numbers are like indexes you can query on. If you're looking for one word, 'defenestrate', it's easy to use the page numbers / index to look up that one query quickly. You don't have to scan every row / word on the As-Cs to find the word: you can skip until you're close. 14 | 15 | However what if your goal is to read the dictionary cover to cover? What use are indexes are you for then? You wouldn't use the page numbers at all in this case (and neither would the planner.) Therefore the planner would still use sequential scan because adding an index just adds unnecessary overhead. 16 | 17 | This isn't just for complete datasets either. Imagine you're reading the whole dataset _except_ the Xs. The X section is so short you'd probably just scan over them to get to the Ys. Same thing with the planner: it'll make a call sometimes to just scan over rows because adding an index would be too much overhead. Basically what I'm saying is that the planner is usually right even if it's unintuitive. 18 | 19 | ## Unique indexes 20 | 21 | So we've seen and used one sort of index already: primary keys (which are normally sequentially increasing integers called `id` or similar.) 22 | 23 | Unique and primary keys create indexes by default and you don't need to do anything else to get them. They do this because everytime you insert into a unique key it needs to go check if it already exists so it can enforce uniqueness, making it necessary to have an index. 24 | 25 | Keep in mind you can also have uniqueness as a constraint across multiple columns. Imagine you stored address, city, and state as three columns. Multiple people could live on `1000 4th Street` across Seattle, San Francisco, Minneapolis, and Savannah, but there can only be one `1000 4th Ave, Seattle, WA`. Thereforce a unique constraint across multiple columns could be really useful. You'd do that with something like 26 | 27 | ```sql 28 | CREATE TABLE american_addresses ( 29 | id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 30 | street_address int NOT NULL, 31 | city VARCHAR(255) NOT NULL, 32 | state VARCHAR(255) NOT NULL, 33 | UNIQUE (street_address, city, state) 34 | ); 35 | ``` 36 | -------------------------------------------------------------------------------- /lessons/09-query-performance/C-create-an-index.md: -------------------------------------------------------------------------------- 1 | Okay, so we've identified that we frequently query movies by exact names and this is a query we want to support. It's a big table (175721 rows) and therefore a sequential scan on this is _pricy_. So how we go about doing that? 2 | 3 | ```sql 4 | EXPLAIN ANALYZE SELECT * FROM movies WHERE name='Tron Legacy'; 5 | 6 | CREATE INDEX idx_name ON movies(name); 7 | 8 | EXPLAIN ANALYZE SELECT * FROM movies WHERE name='Tron Legacy'; 9 | ``` 10 | 11 | Check out the difference there. On my machine I'm seeing a difference between a cost of `4929.51` and `12.30` for the total cost. _Wild_, right? 12 | 13 | Do note I'm also seeing a difference in the startup cost. A sequential scan has a startup cost `0.00` or at least very, very low and the cost of the index for me is `4.44`. This is why sometimes using an index isn't worth it for the planner: using an index isn't _free_. You incur overhead to use it. Think of it doing something like memoization in your code. You don't memoize everything because frequently it buys you nothing with lots of additional code and overhead. Similar idea here. 14 | 15 | ## B-tree versus others 16 | 17 | When you say `CREATE INDEX` you are implicitly saying you want a b-tree index. A [b-tree][btree] is a data structure that is specifically easy to search. 18 | 19 | I teach a lesson on [Frontend Masters][fem] on how to do AVL trees which are a simplification of b-trees if you want to get into how a self-balancing tree works. 20 | 21 | B-trees are compact, fairly simple data structures that are suited to general indexes. However there are a few more to be aware of. We're about to talk about GIN indexes next so let's cover the ones first we're not going to be talking about. 22 | 23 | - Hash - Useful for when you're doing strict equality checks and need it fast and in memory. Can't handle anything other than `=` checks. 24 | - GiST - Can also be used for full text search but ends up with false positives sometimes so GIN is frequently preferred. 25 | - SP-GiST - Another form of GiST. Both GiST and SP-GiST offer a variety of different searchable structures and are much more special-use-case. I've never had to use either. SP-GiST is most useful for clustering types of search and "nearest neighbor" sorts of things. 26 | - BRIN - BRIN is most useful for extremely large tables where maintaing a b-tree isn't feasible. BRIN is smaller and more compact and therefore makes larger table indexing more feasible. 27 | 28 | [This page from the docs about index types][indexes] is helpful to glance at. 29 | 30 | [indexes]: https://www.postgresql.org/docs/current/indexes-types.html 31 | [fem]: https://frontendmasters.com/courses/computer-science-v2/ 32 | [btree]: https://en.wikipedia.org/wiki/B-tree 33 | -------------------------------------------------------------------------------- /lessons/09-query-performance/D-gin.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "GIN" 3 | --- 4 | 5 | GIN stands for generalized inverted index. It's another data structure for indexes. In this case it's really useful for things where multiple values could apply to one row. A good example of this would be with JSONB columns: you have one column but it could have multiple values inside the object that a search could find. Fathom the below example: 6 | 7 | > If you run these queries, you'll see it's still using sequential scan. You need pretty big tables (200+ rows? that's an estimate) for it to start being worth it to use your index. 8 | 9 | ```sql 10 | CREATE TABLE movie_reviews ( 11 | id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 12 | movie_id INTEGER, 13 | scores JSONB NOT NULL 14 | ); 15 | 16 | INSERT INTO 17 | movie_reviews 18 | (movie_id, scores) 19 | VALUES 20 | (21103, '{ "rotten_tomatoes": 94, "washington_post": 50, "nytimes": 45 }'), 21 | (97, '{ "rotten_tomatoes": 87, "washington_post": 40 }'), 22 | (18235, '{ "rolling_stone": 85, "washington_post": 60, "nytimes": 35 }'), 23 | (10625, '{ "rotten_tomatoes": 100, "washington_post": 100, "nytimes": 100, "rolling_stone": 100 }'), 24 | (85014, '{ "nytimes": 67 }'), 25 | (2493, '{ "rotten_tomatoes": 67, "rolling_stone": 89, "nytimes": 85 }'), 26 | (11362, '{ "rotten_tomatoes": 76, "washington_post": 14, "nytimes": 98 }'), 27 | (674, '{ "rotten_tomatoes": 78, "washington_post": 40, "nytimes": 77, "rolling_stone": 54 }'); 28 | 29 | CREATE INDEX ON movie_reviews USING gin(scores); 30 | EXPLAIN ANALYZE SELECT * FROM movie_reviews WHERE scores ? 'rolling_stone'; 31 | EXPLAIN ANALYZE SELECT * FROM movie_reviews WHERE scores @> '{"nytimes": 98}'; 32 | ``` 33 | 34 | The above queries on a large table would be a great fit for a GIN index. 35 | 36 | ## GIN and Full Text Search 37 | 38 | GIN, like we said before, is good for things where you can have one column that have multiple values that can return true. So what if we took our search term (in this case let's search for `star wars`) and broke it down in smaller, searchable pieces? Like, three letter pieces, or as they're called, _trigrams_. This is one way PostgreSQL can handle full text search. 39 | 40 | ```sql 41 | SELECT SHOW_TRGM('star wars'); 42 | ``` 43 | 44 | This shows you how PostgreSQL will break down star wars into multiple trigrams. It'll then search based on these and will use GIN to index these in the same fashioned it can index JSONB. Wild one technology works so well in two different ways. 45 | 46 | ```sql 47 | EXPLAIN ANALYZE SELECT * FROM movies WHERE name ILIKE '%star wars%'; 48 | 49 | CREATE INDEX ON movies USING gin(name gin_trgm_ops); 50 | 51 | EXPLAIN ANALYZE SELECT * FROM movies WHERE name ILIKE '%star wars%'; 52 | ``` 53 | 54 | The `gin_trgm_ops` specifies the kind of indexing we want to do here and we're saying we want `trgm` or trigram operations. This is what you do for full text search. 55 | 56 | You should see a large difference between the two queries. My machine got `cost=0.00..4929.51` for the first query and `cost=28.13..88.65` for the second query. 57 | 58 | > As you can see, using the index is nearly half the cost of the second query and which is why we'd need a pretty big data set for the JSONB query to use the index. 59 | -------------------------------------------------------------------------------- /lessons/09-query-performance/E-partial-indexes.md: -------------------------------------------------------------------------------- 1 | You can also partially index tables. Let's take a look at category_names; 2 | 3 | ```sql 4 | \d category_names 5 | 6 | SELECT * FROM category_names WHERE language = 'en' LIMIT 5; 7 | SELECT COUNT(*) FROM category_names; 8 | SELECT DISTINCT language, COUNT(*) FROM category_names GROUP BY language; 9 | ``` 10 | 11 | We have a big table with 30,000+ rows but it has multiple different languages in the table. Let's say we're _mostly_ querying just the English tags and don't need to refer to the other languages as much. Instead of indexing _everything_ wastefully, we can do a partial index. 12 | 13 | ```sql 14 | CREATE INDEX idx_en_category_names ON category_names(language) WHERE language = 'en'; 15 | EXPLAIN ANALYZE SELECT * FROM category_names WHERE language='en' AND name ILIKE '%animation%' LIMIT 5; 16 | EXPLAIN ANALYZE SELECT * FROM category_names WHERE language='de' AND name ILIKE '%animation%' LIMIT 5; 17 | ``` 18 | 19 | Not as much of a difference here as the table isn't _that_ big and the overhead of the index is not helpful as it could be. Still, on my computer I see the `en` query's cost as `cost=133.69..643.89` and the `de` query's as `cost=0.00..833.55`. Still better! 20 | -------------------------------------------------------------------------------- /lessons/09-query-performance/F-derivative-value-indexes.md: -------------------------------------------------------------------------------- 1 | Let's say you have a view where you want to see the biggest different between revenue and budget (more-or-less profit but keep in mind it's all [Hollywood accounting][hollywood] anyway). 2 | 3 | This is a derived value. It's the budget column subtracted from the revenue column. If you run this query as is it's really expensive. 4 | 5 | ```sql 6 | EXPLAIN ANALYZE SELECT 7 | name, date, revenue, budget, COALESCE((revenue - budget), 0) AS profit 8 | FROM 9 | movies 10 | ORDER BY 11 | profit DESC 12 | LIMIT 10; 13 | ``` 14 | 15 | What if this is a hot path for us? We don't want such a slow query for an important part of our app. We can make a index on a derived value and PostgreSQL will be smart enough to use it. 16 | 17 | ```sql 18 | CREATE INDEX idx_movies_profit ON movies (COALESCE((revenue - budget), 0)); 19 | 20 | EXPLAIN ANALYZE SELECT 21 | name, date, revenue, budget, COALESCE((revenue - budget), 0) AS profit 22 | FROM 23 | movies 24 | ORDER BY 25 | profit DESC 26 | LIMIT 10; 27 | ``` 28 | 29 | The difference here is stark. For my computer it's `cost=7258.76..7259.91 ` for the slow query and `cost=0.42..1.33` for the indexed query. 30 | 31 | ## This is just a taste 32 | 33 | I've shown you just a taste of indexing. Optimizng queries is a whole profession unto itself and anything much more complicated than this I would probably consult a [DBA][dba] or another expert to help me craft well. 34 | 35 | The key here is you typically want to use some sort of monitoring software to watch for slow queries that you're running frequently and then optimize for those. If you have a slow query that only gets run once a day then that's fine for that to be slow (usually, you also don't want to lock your whole database once a day either) or if you have a query that gets run a lot but PostgreSQL is already running it fast then you probably don't need an index either. You want indexes for frequently run queries that are running slow. 36 | 37 | [hollywood]: https://en.wikipedia.org/wiki/Hollywood_accounting 38 | [dba]: https://en.wikipedia.org/wiki/Database_administration 39 | -------------------------------------------------------------------------------- /lessons/09-query-performance/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "fire" 3 | } -------------------------------------------------------------------------------- /lessons/10-views/A-basic-views.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | In the previous lesson we saw a way to make a partial index on the category_names table on only the English category names. What if we could make a mini-table of just those category names so we didn't have to add a `WHERE language='en'` on every single query we do? This what views are: they're lens we can query through that we can treat as if they were just normal tables. 6 | 7 | ```sql 8 | CREATE VIEW 9 | english_category_names 10 | AS 11 | SELECT 12 | category_id, name, language 13 | FROM 14 | category_names 15 | WHERE 16 | language='en'; 17 | ``` 18 | 19 | Now you can do: 20 | 21 | ```sql 22 | SELECT * FROM english_category_names LIMIT 5; 23 | ``` 24 | 25 | A few things to note here: 26 | 27 | - We're querying this view as if it was a normal table. That's the power of views. You get to treat them as normal tables in your querying. 28 | - Imagine you have a contractor working on your app with you but you don't want to give them access to everything in a table that they need to work on. You can make a view and give them only access to that one view. One of the powerful aspects of views. 29 | - Views can utilized underlying indexes. You can't index a view itself but the data itself can be indexed. 30 | 31 | ## Inserting into views 32 | 33 | Some pretty cool we can do here with _this_ view (but not all views) is we can actually insert into the view itself. Because it's simple enough that if we insert a category_id and name with the language it'll be smart enough to forward that on to the correct table. 34 | 35 | ```sql 36 | INSERT INTO english_category_names (category_id, name, language) VALUES (2, 'Brian Holt biopic', 'it'); 37 | ``` 38 | 39 | > Note that we inserted an Italian language even though it's an English table. It's going to enforce that only English works here. 40 | 41 | ## Views with joins 42 | 43 | This is cool to have a filtered view on a table, but let's make it even more compelling. A view can be more-or-less any SELECT query. So we can put joins together so instead of wild joins we can just query a view. 44 | 45 | Let's say you have a page on your app that you need to display actors & actresses, the roles they played, and the movies those roles were in and this was a really common querying pattern for your app. Your query would look like: 46 | 47 | ```sql 48 | SELECT 49 | p.name AS person_name, c.role, m.name AS movie_name, p.id AS person_id, m.id AS movie_id 50 | FROM 51 | people p 52 | 53 | INNER JOIN 54 | casts c 55 | ON 56 | p.id = c.person_id 57 | 58 | INNER JOIN 59 | movies m 60 | ON 61 | c.movie_id = m.id 62 | 63 | WHERE 64 | c.role <> '' 65 | LIMIT 5; 66 | ``` 67 | 68 | It's a bit of a mouthful from a quuerying perspective. So let's make it a view! 69 | 70 | ```sql 71 | CREATE VIEW 72 | actors_roles_movies 73 | AS 74 | SELECT 75 | p.name AS person_name, c.role, m.name AS movie_name, p.id AS person_id, m.id AS movie_id 76 | FROM 77 | people p 78 | 79 | INNER JOIN 80 | casts c 81 | ON 82 | p.id = c.person_id 83 | 84 | INNER JOIN 85 | movies m 86 | ON 87 | c.movie_id = m.id 88 | 89 | WHERE 90 | c.role <> ''; 91 | ``` 92 | 93 | Now you can do 94 | 95 | ```sql 96 | SELECT * FROM actors_roles_movies LIMIT 20; 97 | ``` 98 | 99 | Much easier, right? And now we can do queries with this too! We can treat these views as if they were real tables when we do joins. 100 | 101 | What actors & actresses tend to act in the same sorts of movies? We can know that by joining category names to movie keywords to movies to casts to people, right? Well, here we can make use of both of our views because we already have people to movies connected, and we have another view with just English category names already, so let's use both! 102 | 103 | > This is a very expensive query (most expensive so far, I got `cost=293871.35..293871.40`.) If you have a slow computer, uncomment the WHERE clause. 104 | 105 | ```sql 106 | SELECT 107 | arm.person_name, ecn.name AS keyword, COUNT(*) as count 108 | FROM 109 | actors_roles_movies arm 110 | 111 | INNER JOIN 112 | movie_keywords mk 113 | ON 114 | mk.movie_id = arm.movie_id 115 | 116 | INNER JOIN 117 | english_category_names ecn 118 | ON 119 | ecn.category_id = mk.category_id 120 | 121 | -- WHERE arm.person_name = 'Julia Roberts' 122 | 123 | GROUP BY 124 | arm.person_name, ecn.name 125 | 126 | ORDER BY 127 | count DESC 128 | 129 | LIMIT 20; 130 | ``` 131 | 132 | This is the best part about views! We get the full power of PostgreSQL behind this summarized views that live-derive from our existing data. 133 | -------------------------------------------------------------------------------- /lessons/10-views/B-materialized-views.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "" 3 | --- 4 | 5 | In our last lesson we looked at views and then the final query we ran was _super expensive_. As in `cost=293871.35..293871.40` expensive. That's so expensive that we would not want to run that on every page load. 6 | 7 | So fathom this scenario. You show your boss this great query that shows how actors and actresses cluster around certain movie keywords and they want to ship a new page that shows users this in nice infographics. You think to yourself "oh no, that's such an expensive query, we can't do that on every page if it's really popular page." 8 | 9 | So let's investigate the product problem 10 | 11 | - You want to show users cool graphs with data from your database 12 | - The query to get this data is expensive 13 | - The data itself doesn't update too often. Only a few movies release per week and an actor/actress is only releasing a few movies a year 14 | - This is a good place to use some sort of caching strategy 15 | 16 | So we could defintely use some sort of cache in front of the database. We could that manages setting a memcache or Redis cache of the response and then use that and then have some cron job that updates that cache every X hours or so. 17 | 18 | Or we could use what's called a materialized view. A materialized view is similar to a view but it's not live data. Instead you take a snapshot of what the query is now and then you query _that_ data which is way cheaper. At that point it's just a normal table. You can even index it! 19 | 20 | Let's make a materialized view of our last query. 21 | 22 | ```sql 23 | CREATE MATERIALIZED VIEW 24 | actor_categories 25 | 26 | AS 27 | 28 | SELECT 29 | arm.person_name, ecn.name AS keyword, COUNT(*) as count 30 | FROM 31 | actors_roles_movies arm 32 | 33 | INNER JOIN 34 | movie_keywords mk 35 | ON 36 | mk.movie_id = arm.movie_id 37 | 38 | INNER JOIN 39 | english_category_names ecn 40 | ON 41 | ecn.category_id = mk.category_id 42 | 43 | GROUP BY 44 | arm.person_name, ecn.name 45 | 46 | WITH NO DATA; 47 | ``` 48 | 49 | - We created the materialized view and provided it the query to get the data to populate itself. 50 | - We specified to _not_ yet populate it. Imagine if you were doing this on your production server. You want to be _very_ judicious when you run this expensive query. If you leave out the `NO` part and say `WITH DATA` it'll run this query first time. 51 | 52 | Now if your try `SELECT * FROM actor_categories;` you'll get a "not been populated" error. So let's populate it! 53 | 54 | We have two options: 55 | 56 | - `REFRESH MATERIALIZED VIEW actor_categories;` - this goes faster but it locks the table so queries in the mean time won't work. 57 | - `REFRESH MATERIALIZED VIEW CONCURRENTLY actor_categories;` - this works far slower but doesn't lock the table in the process. Useful if you can't afford downtime on the view. 58 | 59 | Feel free to try both but I'm going to do the first one. 60 | 61 | Amazing, now try 62 | 63 | ```sql 64 | SELECT * FROM actor_categories ORDER BY count DESC NULLS LAST LIMIT 10; 65 | ``` 66 | 67 | _Much faster_. `EXPLAIN ANALYZE` gives us a cost of `cost=88408.08..88409.24` which is about 1/4 of the cost. 68 | 69 | > Note the `NULLS LAST` is necessary or else `NULLS FIRST` is implicit and it won't use our index. 70 | 71 | The `NULLS LAST` portion isn't necessary here since nothing _should_ be null but I wanted to show you this is how you'd handle that. 72 | 73 | So, because this is an independent source of data, it can also be indexed! We can drive this cost _way_ down on this query. 74 | 75 | ```sql 76 | CREATE INDEX idx_actor_categories_count ON actor_categories(count DESC NULLS LAST); 77 | ``` 78 | 79 | - An index, you've seen this before. 80 | - We added a `DESC` to the index because primarily we'll be showing the thing with the _most_ first. But keep in mind that PostgreSQL is perfectly capable of reading these indexes backwards so we can do ASC too on this same index. 81 | - The `NULLS LAST` is wholly unnecessary but I wanted to throw it in there. Nothing in count should be null but if we did you can specify how you want it sorted. 82 | 83 | Okay, let's query it. 84 | 85 | ```sql 86 | EXPLAIN ANALYZE SELECT * FROM actor_categories ORDER BY count DESC NULLS LAST LIMIT 10; 87 | ``` 88 | 89 | A cost of `cost=0.43..0.75` as compared to the original cost of `cost=293871.35..293871.40`. Amazing. This is about a 40,000x increase in performance boost. Not too bad I'd say! 90 | -------------------------------------------------------------------------------- /lessons/10-views/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "eye" 3 | } -------------------------------------------------------------------------------- /lessons/11-subqueries/A-how-to-subquery.md: -------------------------------------------------------------------------------- 1 | Some times you need two queries to do one thing. Some times you can rethink queries to accomplish the same thing but other times it's easier to just have two queries. You could do this in code: query and id and then prepare a second query using the result of the first to get what you're looking for, but it'd be so much easier if we could embed that. And you guessed it, you sure can. 2 | 3 | Let's see how. What if we I wanted to find everyone that was cast in `Tron Legacy` and we don't know the id off the top of our heads? We could absolutely do that with joins but we can actually do it with a subquery too. 4 | 5 | ```sql 6 | SELECT 7 | p.name 8 | FROM 9 | casts c 10 | 11 | INNER JOIN 12 | people p 13 | ON 14 | c.person_id = p.id 15 | 16 | WHERE 17 | c.movie_id = ( 18 | SELECT 19 | id 20 | FROM 21 | movies 22 | WHERE 23 | name = 'Tron Legacy' 24 | ); 25 | ``` 26 | 27 | - The `()` represent that you can doing to do a subquery. This one will run first and then its results will be used in the second query. 28 | - A subquery must return a single column. I think that makes sense here since we're asking for the id from the movies table. 29 | - On one hand nesting makes thing hard to read. On the other hand grasping joins at a glance be a lot of cognitive load. Use your best judgment on which is more "readable" to you. Sometimes it'll be a subquery, sometimes it'll be a join. 30 | 31 | To see how to do this with a join, try this query: 32 | 33 | ```sql 34 | SELECT 35 | p.name 36 | FROM 37 | casts c 38 | 39 | INNER JOIN 40 | people p 41 | ON 42 | c.person_id = p.id 43 | 44 | INNER JOIN 45 | movies m 46 | ON 47 | c.movie_id = m.id 48 | AND 49 | m.name='Tron Legacy'; 50 | ``` 51 | 52 | So which one is "better"? That's really up to you. The cost of the subquery one is `cost=156.73..308.04` and the cost of the inner join one is `cost=148.86..175.03`. That's mostly negligible in a production environment but the join is faster this time. 99% of the time I'd say joins will be faster but frequently it doesn't much matter. When it's close enough, choose which one you can maintain better (generally speaking, optimize queries that are slow and run frequently.) 53 | -------------------------------------------------------------------------------- /lessons/11-subqueries/B-arrays.md: -------------------------------------------------------------------------------- 1 | You _can_ return multiple values from a subquery but you have to handle the results correctly. 2 | 3 | Let's say you wanted to see all the keywords associated with any _Star Wars_ movie. How would you go about doing that? You could do joins and have a row per keyword but that's pretty annoying and you'd need code to unravel that mess and aggregate it likely. A subquery with an ARRAY constructor would do this much better. 4 | 5 | ```sql 6 | SELECT 7 | m.name, 8 | ARRAY( 9 | SELECT 10 | ecn.name 11 | FROM 12 | english_category_names ecn 13 | INNER JOIN 14 | movie_keywords mk 15 | ON 16 | mk.category_id = ecn.category_id 17 | WHERE 18 | m.id = mk.movie_id 19 | LIMIT 5 20 | ) AS keywords 21 | FROM 22 | movies m 23 | WHERE 24 | name ILIKE '%star wars%'; 25 | ``` 26 | 27 | - Array is a datatype in PostgreSQL. Here we're using that to aggregate our answers into one response. 28 | - Notice we can use `m.id` in our subquery despite the fact the subquery doesn't reference movies at all. This is possible to do. The subquery has the context of the query it's inside. Makes it so we don't have to do that join inside of the subquery. 29 | - These kinds of queries can get very expensive very quickly. We essentially made a loop with SQL where every row gets its own SELECT subquery. If the outter query returns a lot of rows and the inner query is expensive this will get out of hand quickly. 30 | -------------------------------------------------------------------------------- /lessons/11-subqueries/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "circle-dot" 3 | } -------------------------------------------------------------------------------- /lessons/12-transactions/A-transactions.md: -------------------------------------------------------------------------------- 1 | > This lesson uses the recipeguru database again. Use `\c recipeguru` to get back to the recipeguru database. 2 | 3 | I know we've been dealing with recipes which has a high tolerance for errors, particularly around making multiple inserts and updates in one query. It's not life threatening if we accidentally publish a recipe without a photo or an ingredient that isn't attached to a recipe. But fathom for a moment usecases where it is critical where you need to do multiple things at once and they all need to happen or zero need to happen. 4 | 5 | A good example of this would be bank accounts. If Bob is transferring $10,000 from his account to Alice's account, we need to subtract $10,000 from Bob's account and add $10,000 to Bob's. **Both** of those need to happen or **neither** of those needs to happen. We need it to be one _atomic_ transaction. Atomic in this case means indivisible, or rather it needs to happen as if it was one single query. If Bob _just_ loses $10,000 and Alice doesn't gain it the bank is committing theft and if Alice _just_ gains $10,000 and Bob doesn't lose it then the bank is losing money by paying Alice. If neither happens, they may be a bit mad that it's taking a long time to get the transfer done but no damage has been done and we can try again. 6 | 7 | This is what transactions are for with PostgreSQL. It allows you to say "hey, PostgreSQL, do all of these things and if anything fails or doesn't work, then do none of it. Let's give it a shot with the recipes database. 8 | 9 | You can really drive home how this works by opening a second terminal to the database and run a 10 | 11 | ```sql 12 | BEGIN; 13 | 14 | INSERT INTO ingredients (title, type) VALUES ('whiskey', 'other'); 15 | INSERT INTO ingredients (title, type) VALUES ('simple syrup', 'other'); 16 | 17 | INSERT INTO recipes (title, body) VALUES ('old fashioned', 'mmmmmmm old fashioned'); 18 | 19 | INSERT INTO recipe_ingredients 20 | (recipe_id, ingredient_id) 21 | VALUES 22 | ( 23 | (SELECT recipe_id FROM recipes where title='old fashioned'), 24 | (SELECT id FROM ingredients where title='whiskey') 25 | ), 26 | ( 27 | (SELECT recipe_id FROM recipes where title='old fashioned'), 28 | (SELECT id FROM ingredients where title='simple syrup') 29 | ); 30 | 31 | COMMIT; 32 | ``` 33 | 34 | - `BEGIN` is how you _start_ a transaction. You're telling PostgreSQL "I'm giving a few queries now, don't run them until I say `COMMIT` and then run _all_ of them. 35 | - If you `BEGIN` and decide you don't want to `COMMIT` you can run `ROLLBACK;` and it will not run any queries. 36 | - If you want really prove a point to yourself, run the `BEGIN` and the first three `INSERT INTO` commands but don't run the last INSERT or the COMMIT. Open a second psql instance and try to query for "whiskey" in the ingredients table. You won't find it because we didn't `COMMIT` yet. 37 | - `BEGIN` is short for `BEGIN WORK` or `BEGIN TRANSACTION`. Any of those work. 38 | 39 | I used subqueries to get the IDs. You can use variables if you use plpgsql language and use `RETURNING INTO` and a variable name. Let's just show you what that could look like. 40 | 41 | ```sql 42 | BEGIN WORK; 43 | 44 | DO $$ 45 | DECLARE champagne INTEGER; 46 | DECLARE orange_juice INTEGER; 47 | DECLARE mimosa INTEGER; 48 | BEGIN 49 | 50 | INSERT INTO ingredients (title, type) VALUES ('champage', 'other') RETURNING id INTO champagne; 51 | INSERT INTO ingredients (title, type) VALUES ('orange_juice', 'other') RETURNING id INTO orange_juice; 52 | 53 | INSERT INTO recipes (title, body) VALUES ('mimosa', 'brunch anyone?') RETURNING recipe_id INTO mimosa; 54 | 55 | INSERT INTO recipe_ingredients 56 | (recipe_id, ingredient_id) 57 | VALUES 58 | (mimosa, champagne), 59 | (mimosa, orange_juice); 60 | 61 | END $$; 62 | 63 | COMMIT WORK; 64 | ``` 65 | 66 | - This is just to show you a different way to do it without subqueries. We're using the plpgpsql feature of variables. This does not work in normal SQL, we have to use the programming language. 67 | - Notice the `BEGIN WORK` and `COMMIT WORK` are **outside** of the function. Postgres can't do transactions inside of functions. Other SQL databases can, just not Postgres. 68 | - There's a `BEGIN WORK` for the transaction and a `BEGIN` for the function. They're different and do different things. I used `BEGIN WORK;` to make it very clear to you but you can use `BEGIN;` and it'd work just fine. 69 | -------------------------------------------------------------------------------- /lessons/12-transactions/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "envelopes-bulk" 3 | } -------------------------------------------------------------------------------- /lessons/13-window-functions/A-window-functions.md: -------------------------------------------------------------------------------- 1 | > Back to movies. `\c omdb` to reconnect to the movies db. 2 | 3 | Let's chat a moment about window functions. We saw one briefly in our Node.js project where we needed it for the count of the whole table. 4 | 5 | What if we wanted aggregate information about part of our rows. Basically a GROUP BY for an individual row. 6 | 7 | Here's an example: our movies table has vote_averages in it. What if for each row we also wanted to see the average vote_average for the whole table? 8 | 9 | ```sql 10 | SELECT 11 | name, kind, vote_average, AVG(vote_average) OVER () AS all_average 12 | FROM 13 | movies 14 | LIMIT 50; 15 | ``` 16 | 17 | This adds an `all_average` column that will be the average vote_average for the entire table. The OVER says we're going to use a window function and the `()` means we're not going to do any more slicing and dicing: we just want to include the whole result set. 18 | 19 | This is sorta useful but what if we wanted to be a bit more granular than that? What we if wanted to see the average of its `kind` (the kind in this table is movie, movieseries, episode, series, etc.)? We can do that with a PARTITION BY statement. 20 | 21 | ```sql 22 | SELECT 23 | name, kind, vote_average, AVG(vote_average) OVER (PARTITION BY kind) AS kind_average 24 | FROM 25 | movies 26 | LIMIT 50; 27 | ``` 28 | 29 | > You may need to add a `WHERE kind='episode'` to see how they can be different since this database is mostly movies. 30 | 31 | The `PARTITION BY` statement is telling PostgreSQL how to slice and dice for the averages. In this case we're saying "give us the average by kind". So if you see a movie, its `kind_average` will be the average of all movies. If you see an episode, its `kind_average` will the average of all episodes. 32 | 33 | To see this together with distinct, let's look at: 34 | 35 | ```sql 36 | SELECT DISTINCT 37 | kind, AVG(vote_average) OVER (PARTITION BY kind) AS kind_vote_average 38 | FROM movies; 39 | ``` 40 | 41 | This now allows us to see at a glance the averages of each kind in a nice table. 42 | -------------------------------------------------------------------------------- /lessons/13-window-functions/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "magnifying-glass-arrow-right" 3 | } -------------------------------------------------------------------------------- /lessons/14-self-join/A-self-join.md: -------------------------------------------------------------------------------- 1 | A little bonus fun puzzle for you. 2 | 3 | You can join tables to themselves. Why would you ever want to do that? 4 | 5 | Let's look at category_names. category_names contains all the names of categories and keywords and it contains multiple languages. The most common languages in our database are English and German. What if I wanted to find words that had German translations but not English? That way I could assign a translator to go translate that so we could have parity. This is a job for a table joined to itself. 6 | 7 | ```sql 8 | SELECT 9 | c1.category_id, c1.language AS de_lang, c1.name as de_name, c2.language AS en_lang, c2.name AS en_name 10 | FROM 11 | category_names c1 12 | 13 | LEFT JOIN 14 | category_names c2 15 | ON 16 | c1.category_id = c2.category_id 17 | AND 18 | c2.language = 'en' 19 | 20 | WHERE 21 | c2.language IS NULL 22 | AND 23 | c1.language = 'de' 24 | 25 | LIMIT 50; 26 | ``` 27 | 28 | - You treat the self join just like any other, just give them different names in your query. I called the "left" side of my equation `c1` and the "right" `c2`. 29 | - I used the LEFT JOIN because I want to have a row for _any_ German category, whether it has a English row to join to or not. 30 | - Then we use WHERE to limit c1 to de and to check to see where the `en` is null (which is left in because of the LEFT JOIN) 31 | - If you set `IS NULL` to `IS NOT NULL` you'll see German and English translations in the same line. 32 | 33 | This is a bit of an advance use case and to be quite clear, I took a while to tinker with this to get it to be just what I wanted. Most people don't get it right the first time, just like programming in general. 34 | -------------------------------------------------------------------------------- /lessons/14-self-join/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "arrow-rotate-left" 3 | } -------------------------------------------------------------------------------- /lessons/15-conclusion/A-congrats.md: -------------------------------------------------------------------------------- 1 | You did it! 2 | 3 | Congratulations, you are well on your way to being an expert at SQL and PostgreSQL. Some final thoughts for you on what you learned today. 4 | 5 | - _Most_ of what you learned today is applicable to \_any SQL database: MySQL, Oracle, SQLite, Microsoft SQL Server, etc. SQL is a general spec that all of these database then take and put their own flavor on. Many of these queries will work as-is on a different database. The rest have to be tweaked slightly to fit the other database's flavor. But all the principles you learned apply. 6 | - This course focused exclusively on how to query a database and was generally focused from a programmer's perspective and _not_ an operations perspective. Notice we didn't talk about replication, configuring a server, deploying, backing up, replication, etc. This is a course for another time (and another teacher!) This was to teach you about basic querying skills. 7 | - We also didn't cover ORMs for Node.js. In general I've had more problems then time saved with using ORMs. I always want to have access to the underlying SQL at the end of the day. I normally need that control. 8 | 9 | We also did not talk a lot about execution order of queries whereas lots of other courses do talk about it. Why? When I learned SQL for the first time I found it _very_ confusing and I focused a lot on it when in reality 90% of the time it doesn't actually matter. Do you care if a `INNER JOIN` or a `WHERE` happens first? Normally no! Hence I left it out and as an exercise to you to explore more if you want to delve into it. Suffice to say, queries break down into a plan from PostgreSQL and then are executed in a particular order. In general you don't have to care. Only time we cared is with `WHERE` vs `HAVING` where HAVING has to happen last. [See this blog post][post] to have a more comprehensive overview. 10 | 11 | Great job again! I hope you enjoyed the course! Please [tweet at me][tweet] what you thought and [give this repo a star][repo]! 12 | 13 | [repo]: https://github.com/btholt/complete-intro-to-sql 14 | [tweet]: https://twitter.com/hotlbt 15 | -------------------------------------------------------------------------------- /lessons/15-conclusion/B-project.md: -------------------------------------------------------------------------------- 1 | As a fun project for you to wrap up this course one, I invite you to make a movies website using some of the knowledge you got here. I'll hand you some designs (made by the amazing designer [Alex Danielson][alex]) but feel free to invent your own and/or add new features beyond what we're showing here. This would be an awesome place to try out data visualization as well. 2 | 3 | The site we have designs for is ReelingIt, a movie info perusing site. You may need to acquire your own photo assets as I didn't want to include copyrighted material in this course. 4 | 5 | [Download all materials here][zip]. 6 | 7 | ![Preview of ReelingIt](/images/Landing.png) 8 | 9 | Have fun, learn some new things, and let me know how you did by [tweeting at me!][tweet] 10 | 11 | [zip]: /ReelingIt.zip 12 | [tweet]: https://twitter.com/holtbt 13 | -------------------------------------------------------------------------------- /lessons/15-conclusion/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "icon": "face-smile-beam" 3 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import path from "path"; 3 | 4 | const buffer = readFileSync(path.join(process.cwd(), "./course.json")); 5 | const course = JSON.parse(buffer); 6 | const BASE_URL = course?.productionBaseUrl || ""; 7 | 8 | const config = { 9 | basePath: BASE_URL, 10 | env: { 11 | BASE_URL, 12 | }, 13 | async redirects() { 14 | if (BASE_URL) { 15 | return [ 16 | { 17 | source: "/", 18 | destination: BASE_URL, 19 | basePath: false, 20 | permanent: false, 21 | }, 22 | ]; 23 | } 24 | return []; 25 | }, 26 | }; 27 | 28 | export default config; 29 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postgres", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "license": "(CC-BY-NC-4.0 OR Apache-2.0)", 8 | "dependencies": { 9 | "@fortawesome/fontawesome-free": "^6.1.1", 10 | "gray-matter": "^4.0.3", 11 | "highlight.js": "^11.5.1", 12 | "marked": "^4.0.16", 13 | "next": "^12.1.6", 14 | "react": "^18.1.0", 15 | "react-dom": "^18.1.0", 16 | "title-case": "^3.0.3" 17 | }, 18 | "devDependencies": { 19 | "convert-array-to-csv": "^2.0.0" 20 | } 21 | }, 22 | "node_modules/@fortawesome/fontawesome-free": { 23 | "version": "6.1.1", 24 | "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz", 25 | "integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==", 26 | "hasInstallScript": true, 27 | "engines": { 28 | "node": ">=6" 29 | } 30 | }, 31 | "node_modules/@next/env": { 32 | "version": "12.1.6", 33 | "resolved": "https://registry.npmjs.org/@next/env/-/env-12.1.6.tgz", 34 | "integrity": "sha512-Te/OBDXFSodPU6jlXYPAXpmZr/AkG6DCATAxttQxqOWaq6eDFX25Db3dK0120GZrSZmv4QCe9KsZmJKDbWs4OA==" 35 | }, 36 | "node_modules/@next/swc-android-arm-eabi": { 37 | "version": "12.1.6", 38 | "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.1.6.tgz", 39 | "integrity": "sha512-BxBr3QAAAXWgk/K7EedvzxJr2dE014mghBSA9iOEAv0bMgF+MRq4PoASjuHi15M2zfowpcRG8XQhMFtxftCleQ==", 40 | "cpu": [ 41 | "arm" 42 | ], 43 | "optional": true, 44 | "os": [ 45 | "android" 46 | ], 47 | "engines": { 48 | "node": ">= 10" 49 | } 50 | }, 51 | "node_modules/@next/swc-android-arm64": { 52 | "version": "12.1.6", 53 | "resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-12.1.6.tgz", 54 | "integrity": "sha512-EboEk3ROYY7U6WA2RrMt/cXXMokUTXXfnxe2+CU+DOahvbrO8QSWhlBl9I9ZbFzJx28AGB9Yo3oQHCvph/4Lew==", 55 | "cpu": [ 56 | "arm64" 57 | ], 58 | "optional": true, 59 | "os": [ 60 | "android" 61 | ], 62 | "engines": { 63 | "node": ">= 10" 64 | } 65 | }, 66 | "node_modules/@next/swc-darwin-arm64": { 67 | "version": "12.1.6", 68 | "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.1.6.tgz", 69 | "integrity": "sha512-P0EXU12BMSdNj1F7vdkP/VrYDuCNwBExtRPDYawgSUakzi6qP0iKJpya2BuLvNzXx+XPU49GFuDC5X+SvY0mOw==", 70 | "cpu": [ 71 | "arm64" 72 | ], 73 | "optional": true, 74 | "os": [ 75 | "darwin" 76 | ], 77 | "engines": { 78 | "node": ">= 10" 79 | } 80 | }, 81 | "node_modules/@next/swc-darwin-x64": { 82 | "version": "12.1.6", 83 | "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-12.1.6.tgz", 84 | "integrity": "sha512-9FptMnbgHJK3dRDzfTpexs9S2hGpzOQxSQbe8omz6Pcl7rnEp9x4uSEKY51ho85JCjL4d0tDLBcXEJZKKLzxNg==", 85 | "cpu": [ 86 | "x64" 87 | ], 88 | "optional": true, 89 | "os": [ 90 | "darwin" 91 | ], 92 | "engines": { 93 | "node": ">= 10" 94 | } 95 | }, 96 | "node_modules/@next/swc-linux-arm-gnueabihf": { 97 | "version": "12.1.6", 98 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.1.6.tgz", 99 | "integrity": "sha512-PvfEa1RR55dsik/IDkCKSFkk6ODNGJqPY3ysVUZqmnWMDSuqFtf7BPWHFa/53znpvVB5XaJ5Z1/6aR5CTIqxPw==", 100 | "cpu": [ 101 | "arm" 102 | ], 103 | "optional": true, 104 | "os": [ 105 | "linux" 106 | ], 107 | "engines": { 108 | "node": ">= 10" 109 | } 110 | }, 111 | "node_modules/@next/swc-linux-arm64-gnu": { 112 | "version": "12.1.6", 113 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.1.6.tgz", 114 | "integrity": "sha512-53QOvX1jBbC2ctnmWHyRhMajGq7QZfl974WYlwclXarVV418X7ed7o/EzGY+YVAEKzIVaAB9JFFWGXn8WWo0gQ==", 115 | "cpu": [ 116 | "arm64" 117 | ], 118 | "optional": true, 119 | "os": [ 120 | "linux" 121 | ], 122 | "engines": { 123 | "node": ">= 10" 124 | } 125 | }, 126 | "node_modules/@next/swc-linux-arm64-musl": { 127 | "version": "12.1.6", 128 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.1.6.tgz", 129 | "integrity": "sha512-CMWAkYqfGdQCS+uuMA1A2UhOfcUYeoqnTW7msLr2RyYAys15pD960hlDfq7QAi8BCAKk0sQ2rjsl0iqMyziohQ==", 130 | "cpu": [ 131 | "arm64" 132 | ], 133 | "optional": true, 134 | "os": [ 135 | "linux" 136 | ], 137 | "engines": { 138 | "node": ">= 10" 139 | } 140 | }, 141 | "node_modules/@next/swc-linux-x64-gnu": { 142 | "version": "12.1.6", 143 | "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.1.6.tgz", 144 | "integrity": "sha512-AC7jE4Fxpn0s3ujngClIDTiEM/CQiB2N2vkcyWWn6734AmGT03Duq6RYtPMymFobDdAtZGFZd5nR95WjPzbZAQ==", 145 | "cpu": [ 146 | "x64" 147 | ], 148 | "optional": true, 149 | "os": [ 150 | "linux" 151 | ], 152 | "engines": { 153 | "node": ">= 10" 154 | } 155 | }, 156 | "node_modules/@next/swc-linux-x64-musl": { 157 | "version": "12.1.6", 158 | "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.1.6.tgz", 159 | "integrity": "sha512-c9Vjmi0EVk0Kou2qbrynskVarnFwfYIi+wKufR9Ad7/IKKuP6aEhOdZiIIdKsYWRtK2IWRF3h3YmdnEa2WLUag==", 160 | "cpu": [ 161 | "x64" 162 | ], 163 | "optional": true, 164 | "os": [ 165 | "linux" 166 | ], 167 | "engines": { 168 | "node": ">= 10" 169 | } 170 | }, 171 | "node_modules/@next/swc-win32-arm64-msvc": { 172 | "version": "12.1.6", 173 | "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.1.6.tgz", 174 | "integrity": "sha512-3UTOL/5XZSKFelM7qN0it35o3Cegm6LsyuERR3/OoqEExyj3aCk7F025b54/707HTMAnjlvQK3DzLhPu/xxO4g==", 175 | "cpu": [ 176 | "arm64" 177 | ], 178 | "optional": true, 179 | "os": [ 180 | "win32" 181 | ], 182 | "engines": { 183 | "node": ">= 10" 184 | } 185 | }, 186 | "node_modules/@next/swc-win32-ia32-msvc": { 187 | "version": "12.1.6", 188 | "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.1.6.tgz", 189 | "integrity": "sha512-8ZWoj6nCq6fI1yCzKq6oK0jE6Mxlz4MrEsRyu0TwDztWQWe7rh4XXGLAa2YVPatYcHhMcUL+fQQbqd1MsgaSDA==", 190 | "cpu": [ 191 | "ia32" 192 | ], 193 | "optional": true, 194 | "os": [ 195 | "win32" 196 | ], 197 | "engines": { 198 | "node": ">= 10" 199 | } 200 | }, 201 | "node_modules/@next/swc-win32-x64-msvc": { 202 | "version": "12.1.6", 203 | "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.1.6.tgz", 204 | "integrity": "sha512-4ZEwiRuZEicXhXqmhw3+de8Z4EpOLQj/gp+D9fFWo6ii6W1kBkNNvvEx4A90ugppu+74pT1lIJnOuz3A9oQeJA==", 205 | "cpu": [ 206 | "x64" 207 | ], 208 | "optional": true, 209 | "os": [ 210 | "win32" 211 | ], 212 | "engines": { 213 | "node": ">= 10" 214 | } 215 | }, 216 | "node_modules/argparse": { 217 | "version": "1.0.10", 218 | "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", 219 | "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", 220 | "dependencies": { 221 | "sprintf-js": "~1.0.2" 222 | } 223 | }, 224 | "node_modules/caniuse-lite": { 225 | "version": "1.0.30001346", 226 | "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001346.tgz", 227 | "integrity": "sha512-q6ibZUO2t88QCIPayP/euuDREq+aMAxFE5S70PkrLh0iTDj/zEhgvJRKC2+CvXY6EWc6oQwUR48lL5vCW6jiXQ==", 228 | "funding": [ 229 | { 230 | "type": "opencollective", 231 | "url": "https://opencollective.com/browserslist" 232 | }, 233 | { 234 | "type": "tidelift", 235 | "url": "https://tidelift.com/funding/github/npm/caniuse-lite" 236 | } 237 | ] 238 | }, 239 | "node_modules/convert-array-to-csv": { 240 | "version": "2.0.0", 241 | "resolved": "https://registry.npmjs.org/convert-array-to-csv/-/convert-array-to-csv-2.0.0.tgz", 242 | "integrity": "sha512-dxUINCt28k6WbXGMoB+AaKjGY0Y6GkKwZmT+kvD4nJgVCOKsnIQ3G6n0v2II1lG4NwXQk6EWZ+pPDub9wcqqMg==", 243 | "dev": true 244 | }, 245 | "node_modules/esprima": { 246 | "version": "4.0.1", 247 | "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", 248 | "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", 249 | "bin": { 250 | "esparse": "bin/esparse.js", 251 | "esvalidate": "bin/esvalidate.js" 252 | }, 253 | "engines": { 254 | "node": ">=4" 255 | } 256 | }, 257 | "node_modules/extend-shallow": { 258 | "version": "2.0.1", 259 | "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", 260 | "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", 261 | "dependencies": { 262 | "is-extendable": "^0.1.0" 263 | }, 264 | "engines": { 265 | "node": ">=0.10.0" 266 | } 267 | }, 268 | "node_modules/gray-matter": { 269 | "version": "4.0.3", 270 | "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", 271 | "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", 272 | "dependencies": { 273 | "js-yaml": "^3.13.1", 274 | "kind-of": "^6.0.2", 275 | "section-matter": "^1.0.0", 276 | "strip-bom-string": "^1.0.0" 277 | }, 278 | "engines": { 279 | "node": ">=6.0" 280 | } 281 | }, 282 | "node_modules/highlight.js": { 283 | "version": "11.5.1", 284 | "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.5.1.tgz", 285 | "integrity": "sha512-LKzHqnxr4CrD2YsNoIf/o5nJ09j4yi/GcH5BnYz9UnVpZdS4ucMgvP61TDty5xJcFGRjnH4DpujkS9bHT3hq0Q==", 286 | "engines": { 287 | "node": ">=12.0.0" 288 | } 289 | }, 290 | "node_modules/is-extendable": { 291 | "version": "0.1.1", 292 | "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", 293 | "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", 294 | "engines": { 295 | "node": ">=0.10.0" 296 | } 297 | }, 298 | "node_modules/js-tokens": { 299 | "version": "4.0.0", 300 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 301 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 302 | }, 303 | "node_modules/js-yaml": { 304 | "version": "3.14.1", 305 | "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", 306 | "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", 307 | "dependencies": { 308 | "argparse": "^1.0.7", 309 | "esprima": "^4.0.0" 310 | }, 311 | "bin": { 312 | "js-yaml": "bin/js-yaml.js" 313 | } 314 | }, 315 | "node_modules/kind-of": { 316 | "version": "6.0.3", 317 | "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", 318 | "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", 319 | "engines": { 320 | "node": ">=0.10.0" 321 | } 322 | }, 323 | "node_modules/loose-envify": { 324 | "version": "1.4.0", 325 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 326 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 327 | "dependencies": { 328 | "js-tokens": "^3.0.0 || ^4.0.0" 329 | }, 330 | "bin": { 331 | "loose-envify": "cli.js" 332 | } 333 | }, 334 | "node_modules/marked": { 335 | "version": "4.0.16", 336 | "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.16.tgz", 337 | "integrity": "sha512-wahonIQ5Jnyatt2fn8KqF/nIqZM8mh3oRu2+l5EANGMhu6RFjiSG52QNE2eWzFMI94HqYSgN184NurgNG6CztA==", 338 | "bin": { 339 | "marked": "bin/marked.js" 340 | }, 341 | "engines": { 342 | "node": ">= 12" 343 | } 344 | }, 345 | "node_modules/nanoid": { 346 | "version": "3.3.2", 347 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.2.tgz", 348 | "integrity": "sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA==", 349 | "bin": { 350 | "nanoid": "bin/nanoid.cjs" 351 | }, 352 | "engines": { 353 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 354 | } 355 | }, 356 | "node_modules/next": { 357 | "version": "12.1.6", 358 | "resolved": "https://registry.npmjs.org/next/-/next-12.1.6.tgz", 359 | "integrity": "sha512-cebwKxL3/DhNKfg9tPZDQmbRKjueqykHHbgaoG4VBRH3AHQJ2HO0dbKFiS1hPhe1/qgc2d/hFeadsbPicmLD+A==", 360 | "dependencies": { 361 | "@next/env": "12.1.6", 362 | "caniuse-lite": "^1.0.30001332", 363 | "postcss": "8.4.5", 364 | "styled-jsx": "5.0.2" 365 | }, 366 | "bin": { 367 | "next": "dist/bin/next" 368 | }, 369 | "engines": { 370 | "node": ">=12.22.0" 371 | }, 372 | "optionalDependencies": { 373 | "@next/swc-android-arm-eabi": "12.1.6", 374 | "@next/swc-android-arm64": "12.1.6", 375 | "@next/swc-darwin-arm64": "12.1.6", 376 | "@next/swc-darwin-x64": "12.1.6", 377 | "@next/swc-linux-arm-gnueabihf": "12.1.6", 378 | "@next/swc-linux-arm64-gnu": "12.1.6", 379 | "@next/swc-linux-arm64-musl": "12.1.6", 380 | "@next/swc-linux-x64-gnu": "12.1.6", 381 | "@next/swc-linux-x64-musl": "12.1.6", 382 | "@next/swc-win32-arm64-msvc": "12.1.6", 383 | "@next/swc-win32-ia32-msvc": "12.1.6", 384 | "@next/swc-win32-x64-msvc": "12.1.6" 385 | }, 386 | "peerDependencies": { 387 | "fibers": ">= 3.1.0", 388 | "node-sass": "^6.0.0 || ^7.0.0", 389 | "react": "^17.0.2 || ^18.0.0-0", 390 | "react-dom": "^17.0.2 || ^18.0.0-0", 391 | "sass": "^1.3.0" 392 | }, 393 | "peerDependenciesMeta": { 394 | "fibers": { 395 | "optional": true 396 | }, 397 | "node-sass": { 398 | "optional": true 399 | }, 400 | "sass": { 401 | "optional": true 402 | } 403 | } 404 | }, 405 | "node_modules/picocolors": { 406 | "version": "1.0.0", 407 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 408 | "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" 409 | }, 410 | "node_modules/postcss": { 411 | "version": "8.4.5", 412 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz", 413 | "integrity": "sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==", 414 | "dependencies": { 415 | "nanoid": "^3.1.30", 416 | "picocolors": "^1.0.0", 417 | "source-map-js": "^1.0.1" 418 | }, 419 | "engines": { 420 | "node": "^10 || ^12 || >=14" 421 | }, 422 | "funding": { 423 | "type": "opencollective", 424 | "url": "https://opencollective.com/postcss/" 425 | } 426 | }, 427 | "node_modules/react": { 428 | "version": "18.1.0", 429 | "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", 430 | "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", 431 | "dependencies": { 432 | "loose-envify": "^1.1.0" 433 | }, 434 | "engines": { 435 | "node": ">=0.10.0" 436 | } 437 | }, 438 | "node_modules/react-dom": { 439 | "version": "18.1.0", 440 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", 441 | "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", 442 | "dependencies": { 443 | "loose-envify": "^1.1.0", 444 | "scheduler": "^0.22.0" 445 | }, 446 | "peerDependencies": { 447 | "react": "^18.1.0" 448 | } 449 | }, 450 | "node_modules/scheduler": { 451 | "version": "0.22.0", 452 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", 453 | "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", 454 | "dependencies": { 455 | "loose-envify": "^1.1.0" 456 | } 457 | }, 458 | "node_modules/section-matter": { 459 | "version": "1.0.0", 460 | "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", 461 | "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", 462 | "dependencies": { 463 | "extend-shallow": "^2.0.1", 464 | "kind-of": "^6.0.0" 465 | }, 466 | "engines": { 467 | "node": ">=4" 468 | } 469 | }, 470 | "node_modules/source-map-js": { 471 | "version": "1.0.2", 472 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", 473 | "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", 474 | "engines": { 475 | "node": ">=0.10.0" 476 | } 477 | }, 478 | "node_modules/sprintf-js": { 479 | "version": "1.0.3", 480 | "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", 481 | "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" 482 | }, 483 | "node_modules/strip-bom-string": { 484 | "version": "1.0.0", 485 | "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", 486 | "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=", 487 | "engines": { 488 | "node": ">=0.10.0" 489 | } 490 | }, 491 | "node_modules/styled-jsx": { 492 | "version": "5.0.2", 493 | "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.2.tgz", 494 | "integrity": "sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==", 495 | "engines": { 496 | "node": ">= 12.0.0" 497 | }, 498 | "peerDependencies": { 499 | "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" 500 | }, 501 | "peerDependenciesMeta": { 502 | "@babel/core": { 503 | "optional": true 504 | }, 505 | "babel-plugin-macros": { 506 | "optional": true 507 | } 508 | } 509 | }, 510 | "node_modules/title-case": { 511 | "version": "3.0.3", 512 | "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", 513 | "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", 514 | "dependencies": { 515 | "tslib": "^2.0.3" 516 | } 517 | }, 518 | "node_modules/tslib": { 519 | "version": "2.3.1", 520 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", 521 | "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" 522 | } 523 | }, 524 | "dependencies": { 525 | "@fortawesome/fontawesome-free": { 526 | "version": "6.1.1", 527 | "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz", 528 | "integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==" 529 | }, 530 | "@next/env": { 531 | "version": "12.1.6", 532 | "resolved": "https://registry.npmjs.org/@next/env/-/env-12.1.6.tgz", 533 | "integrity": "sha512-Te/OBDXFSodPU6jlXYPAXpmZr/AkG6DCATAxttQxqOWaq6eDFX25Db3dK0120GZrSZmv4QCe9KsZmJKDbWs4OA==" 534 | }, 535 | "@next/swc-android-arm-eabi": { 536 | "version": "12.1.6", 537 | "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.1.6.tgz", 538 | "integrity": "sha512-BxBr3QAAAXWgk/K7EedvzxJr2dE014mghBSA9iOEAv0bMgF+MRq4PoASjuHi15M2zfowpcRG8XQhMFtxftCleQ==", 539 | "optional": true 540 | }, 541 | "@next/swc-android-arm64": { 542 | "version": "12.1.6", 543 | "resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-12.1.6.tgz", 544 | "integrity": "sha512-EboEk3ROYY7U6WA2RrMt/cXXMokUTXXfnxe2+CU+DOahvbrO8QSWhlBl9I9ZbFzJx28AGB9Yo3oQHCvph/4Lew==", 545 | "optional": true 546 | }, 547 | "@next/swc-darwin-arm64": { 548 | "version": "12.1.6", 549 | "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.1.6.tgz", 550 | "integrity": "sha512-P0EXU12BMSdNj1F7vdkP/VrYDuCNwBExtRPDYawgSUakzi6qP0iKJpya2BuLvNzXx+XPU49GFuDC5X+SvY0mOw==", 551 | "optional": true 552 | }, 553 | "@next/swc-darwin-x64": { 554 | "version": "12.1.6", 555 | "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-12.1.6.tgz", 556 | "integrity": "sha512-9FptMnbgHJK3dRDzfTpexs9S2hGpzOQxSQbe8omz6Pcl7rnEp9x4uSEKY51ho85JCjL4d0tDLBcXEJZKKLzxNg==", 557 | "optional": true 558 | }, 559 | "@next/swc-linux-arm-gnueabihf": { 560 | "version": "12.1.6", 561 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.1.6.tgz", 562 | "integrity": "sha512-PvfEa1RR55dsik/IDkCKSFkk6ODNGJqPY3ysVUZqmnWMDSuqFtf7BPWHFa/53znpvVB5XaJ5Z1/6aR5CTIqxPw==", 563 | "optional": true 564 | }, 565 | "@next/swc-linux-arm64-gnu": { 566 | "version": "12.1.6", 567 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.1.6.tgz", 568 | "integrity": "sha512-53QOvX1jBbC2ctnmWHyRhMajGq7QZfl974WYlwclXarVV418X7ed7o/EzGY+YVAEKzIVaAB9JFFWGXn8WWo0gQ==", 569 | "optional": true 570 | }, 571 | "@next/swc-linux-arm64-musl": { 572 | "version": "12.1.6", 573 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.1.6.tgz", 574 | "integrity": "sha512-CMWAkYqfGdQCS+uuMA1A2UhOfcUYeoqnTW7msLr2RyYAys15pD960hlDfq7QAi8BCAKk0sQ2rjsl0iqMyziohQ==", 575 | "optional": true 576 | }, 577 | "@next/swc-linux-x64-gnu": { 578 | "version": "12.1.6", 579 | "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.1.6.tgz", 580 | "integrity": "sha512-AC7jE4Fxpn0s3ujngClIDTiEM/CQiB2N2vkcyWWn6734AmGT03Duq6RYtPMymFobDdAtZGFZd5nR95WjPzbZAQ==", 581 | "optional": true 582 | }, 583 | "@next/swc-linux-x64-musl": { 584 | "version": "12.1.6", 585 | "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.1.6.tgz", 586 | "integrity": "sha512-c9Vjmi0EVk0Kou2qbrynskVarnFwfYIi+wKufR9Ad7/IKKuP6aEhOdZiIIdKsYWRtK2IWRF3h3YmdnEa2WLUag==", 587 | "optional": true 588 | }, 589 | "@next/swc-win32-arm64-msvc": { 590 | "version": "12.1.6", 591 | "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.1.6.tgz", 592 | "integrity": "sha512-3UTOL/5XZSKFelM7qN0it35o3Cegm6LsyuERR3/OoqEExyj3aCk7F025b54/707HTMAnjlvQK3DzLhPu/xxO4g==", 593 | "optional": true 594 | }, 595 | "@next/swc-win32-ia32-msvc": { 596 | "version": "12.1.6", 597 | "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.1.6.tgz", 598 | "integrity": "sha512-8ZWoj6nCq6fI1yCzKq6oK0jE6Mxlz4MrEsRyu0TwDztWQWe7rh4XXGLAa2YVPatYcHhMcUL+fQQbqd1MsgaSDA==", 599 | "optional": true 600 | }, 601 | "@next/swc-win32-x64-msvc": { 602 | "version": "12.1.6", 603 | "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.1.6.tgz", 604 | "integrity": "sha512-4ZEwiRuZEicXhXqmhw3+de8Z4EpOLQj/gp+D9fFWo6ii6W1kBkNNvvEx4A90ugppu+74pT1lIJnOuz3A9oQeJA==", 605 | "optional": true 606 | }, 607 | "argparse": { 608 | "version": "1.0.10", 609 | "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", 610 | "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", 611 | "requires": { 612 | "sprintf-js": "~1.0.2" 613 | } 614 | }, 615 | "caniuse-lite": { 616 | "version": "1.0.30001346", 617 | "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001346.tgz", 618 | "integrity": "sha512-q6ibZUO2t88QCIPayP/euuDREq+aMAxFE5S70PkrLh0iTDj/zEhgvJRKC2+CvXY6EWc6oQwUR48lL5vCW6jiXQ==" 619 | }, 620 | "convert-array-to-csv": { 621 | "version": "2.0.0", 622 | "resolved": "https://registry.npmjs.org/convert-array-to-csv/-/convert-array-to-csv-2.0.0.tgz", 623 | "integrity": "sha512-dxUINCt28k6WbXGMoB+AaKjGY0Y6GkKwZmT+kvD4nJgVCOKsnIQ3G6n0v2II1lG4NwXQk6EWZ+pPDub9wcqqMg==", 624 | "dev": true 625 | }, 626 | "esprima": { 627 | "version": "4.0.1", 628 | "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", 629 | "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" 630 | }, 631 | "extend-shallow": { 632 | "version": "2.0.1", 633 | "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", 634 | "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", 635 | "requires": { 636 | "is-extendable": "^0.1.0" 637 | } 638 | }, 639 | "gray-matter": { 640 | "version": "4.0.3", 641 | "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", 642 | "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", 643 | "requires": { 644 | "js-yaml": "^3.13.1", 645 | "kind-of": "^6.0.2", 646 | "section-matter": "^1.0.0", 647 | "strip-bom-string": "^1.0.0" 648 | } 649 | }, 650 | "highlight.js": { 651 | "version": "11.5.1", 652 | "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.5.1.tgz", 653 | "integrity": "sha512-LKzHqnxr4CrD2YsNoIf/o5nJ09j4yi/GcH5BnYz9UnVpZdS4ucMgvP61TDty5xJcFGRjnH4DpujkS9bHT3hq0Q==" 654 | }, 655 | "is-extendable": { 656 | "version": "0.1.1", 657 | "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", 658 | "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" 659 | }, 660 | "js-tokens": { 661 | "version": "4.0.0", 662 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 663 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 664 | }, 665 | "js-yaml": { 666 | "version": "3.14.1", 667 | "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", 668 | "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", 669 | "requires": { 670 | "argparse": "^1.0.7", 671 | "esprima": "^4.0.0" 672 | } 673 | }, 674 | "kind-of": { 675 | "version": "6.0.3", 676 | "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", 677 | "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" 678 | }, 679 | "loose-envify": { 680 | "version": "1.4.0", 681 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 682 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 683 | "requires": { 684 | "js-tokens": "^3.0.0 || ^4.0.0" 685 | } 686 | }, 687 | "marked": { 688 | "version": "4.0.16", 689 | "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.16.tgz", 690 | "integrity": "sha512-wahonIQ5Jnyatt2fn8KqF/nIqZM8mh3oRu2+l5EANGMhu6RFjiSG52QNE2eWzFMI94HqYSgN184NurgNG6CztA==" 691 | }, 692 | "nanoid": { 693 | "version": "3.3.2", 694 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.2.tgz", 695 | "integrity": "sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA==" 696 | }, 697 | "next": { 698 | "version": "12.1.6", 699 | "resolved": "https://registry.npmjs.org/next/-/next-12.1.6.tgz", 700 | "integrity": "sha512-cebwKxL3/DhNKfg9tPZDQmbRKjueqykHHbgaoG4VBRH3AHQJ2HO0dbKFiS1hPhe1/qgc2d/hFeadsbPicmLD+A==", 701 | "requires": { 702 | "@next/env": "12.1.6", 703 | "@next/swc-android-arm-eabi": "12.1.6", 704 | "@next/swc-android-arm64": "12.1.6", 705 | "@next/swc-darwin-arm64": "12.1.6", 706 | "@next/swc-darwin-x64": "12.1.6", 707 | "@next/swc-linux-arm-gnueabihf": "12.1.6", 708 | "@next/swc-linux-arm64-gnu": "12.1.6", 709 | "@next/swc-linux-arm64-musl": "12.1.6", 710 | "@next/swc-linux-x64-gnu": "12.1.6", 711 | "@next/swc-linux-x64-musl": "12.1.6", 712 | "@next/swc-win32-arm64-msvc": "12.1.6", 713 | "@next/swc-win32-ia32-msvc": "12.1.6", 714 | "@next/swc-win32-x64-msvc": "12.1.6", 715 | "caniuse-lite": "^1.0.30001332", 716 | "postcss": "8.4.5", 717 | "styled-jsx": "5.0.2" 718 | } 719 | }, 720 | "picocolors": { 721 | "version": "1.0.0", 722 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 723 | "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" 724 | }, 725 | "postcss": { 726 | "version": "8.4.5", 727 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz", 728 | "integrity": "sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==", 729 | "requires": { 730 | "nanoid": "^3.1.30", 731 | "picocolors": "^1.0.0", 732 | "source-map-js": "^1.0.1" 733 | } 734 | }, 735 | "react": { 736 | "version": "18.1.0", 737 | "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", 738 | "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", 739 | "requires": { 740 | "loose-envify": "^1.1.0" 741 | } 742 | }, 743 | "react-dom": { 744 | "version": "18.1.0", 745 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", 746 | "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", 747 | "requires": { 748 | "loose-envify": "^1.1.0", 749 | "scheduler": "^0.22.0" 750 | } 751 | }, 752 | "scheduler": { 753 | "version": "0.22.0", 754 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", 755 | "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", 756 | "requires": { 757 | "loose-envify": "^1.1.0" 758 | } 759 | }, 760 | "section-matter": { 761 | "version": "1.0.0", 762 | "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", 763 | "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", 764 | "requires": { 765 | "extend-shallow": "^2.0.1", 766 | "kind-of": "^6.0.0" 767 | } 768 | }, 769 | "source-map-js": { 770 | "version": "1.0.2", 771 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", 772 | "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" 773 | }, 774 | "sprintf-js": { 775 | "version": "1.0.3", 776 | "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", 777 | "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" 778 | }, 779 | "strip-bom-string": { 780 | "version": "1.0.0", 781 | "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", 782 | "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=" 783 | }, 784 | "styled-jsx": { 785 | "version": "5.0.2", 786 | "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.2.tgz", 787 | "integrity": "sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ==", 788 | "requires": {} 789 | }, 790 | "title-case": { 791 | "version": "3.0.3", 792 | "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", 793 | "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", 794 | "requires": { 795 | "tslib": "^2.0.3" 796 | } 797 | }, 798 | "tslib": { 799 | "version": "2.3.1", 800 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", 801 | "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" 802 | } 803 | } 804 | } 805 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "license": "(CC-BY-NC-4.0 OR Apache-2.0)", 5 | "author": "Brian Holt ", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build && npm run csv", 9 | "export": "next build && next export && npm run csv", 10 | "start": "next start", 11 | "csv": "node csv/index.js" 12 | }, 13 | "dependencies": { 14 | "@fortawesome/fontawesome-free": "^6.1.1", 15 | "gray-matter": "^4.0.3", 16 | "highlight.js": "^11.5.1", 17 | "marked": "^4.0.16", 18 | "next": "^12.1.6", 19 | "react": "^18.1.0", 20 | "react-dom": "^18.1.0", 21 | "title-case": "^3.0.3" 22 | }, 23 | "devDependencies": { 24 | "convert-array-to-csv": "^2.0.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import "@fortawesome/fontawesome-free/css/all.css"; 3 | 4 | import "highlight.js/styles/a11y-light.css"; 5 | import "../styles/variables.css"; 6 | import "../styles/footer.css"; 7 | import "../styles/courses.css"; 8 | 9 | import Layout from "../components/layout"; 10 | 11 | export default function App({ Component, pageProps }) { 12 | return ( 13 | 14 | 15 | 20 | 26 | 32 | 38 | 43 | 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | 4 | import { getLessons } from "../data/lesson"; 5 | 6 | import Corner from "../components/corner"; 7 | import getCourseConfig from "../data/course"; 8 | 9 | export default function Lessons({ sections }) { 10 | const courseInfo = getCourseConfig(); 11 | return ( 12 | <> 13 | 14 | {courseInfo.title} 15 | 16 | 17 | 18 | 19 | 23 | 24 | 25 |
26 |
27 |
28 |
29 |

{courseInfo.title}

30 |

{courseInfo.subtitle}

31 |
32 |
33 | author image 38 |
39 |
40 |
{courseInfo.author.name}
41 |
{courseInfo.author.company}
42 |
43 |
44 |
45 |
46 |
47 | course icon 51 |
52 |
53 | {courseInfo.frontendMastersLink ? ( 54 | 55 | Watch on Frontend Masters 56 | 57 | ) : null} 58 |
59 |

Table of Contents

60 |
61 |
    62 | {sections.map((section) => ( 63 |
  1. 64 |
    65 |
    66 | 67 |
    68 |
    69 |

    {section.title}

    70 |
      71 | {section.lessons.map((lesson) => ( 72 |
    1. 73 | {lesson.title} 74 |
    2. 75 | ))} 76 |
    77 |
    78 | 79 |
    80 |
  2. 81 | ))} 82 |
83 |
84 |
85 |
86 | 87 | ); 88 | } 89 | 90 | export async function getStaticProps() { 91 | const sections = await getLessons(); 92 | return { 93 | props: { 94 | sections, 95 | }, 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /pages/lessons/[section]/[slug].js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from "react"; 2 | import Head from "next/head"; 3 | import { getLesson, getLessons } from "../../../data/lesson"; 4 | import getCourseConfig from "../../../data/course"; 5 | import Corner from "../../../components/corner"; 6 | import { Context } from "../../../context/headerContext"; 7 | 8 | export default function LessonSlug({ post }) { 9 | const courseInfo = getCourseConfig(); 10 | const [_, setHeader] = useContext(Context); 11 | useEffect(() => { 12 | setHeader({ 13 | section: post.section, 14 | title: post.title, 15 | icon: post.icon, 16 | }); 17 | return () => setHeader({}); 18 | }, []); 19 | 20 | const title = post.title 21 | ? `${post.title} – ${courseInfo.title}` 22 | : courseInfo.title; 23 | const description = post.description 24 | ? post.description 25 | : courseInfo.description; 26 | 27 | return ( 28 | <> 29 | 30 | {title} 31 | 32 | {/* */} 33 | 34 | 35 | 39 | 40 | 41 |
42 |
43 |
47 |
48 | {post.prevSlug ? ( 49 | 50 | ← Previous 51 | 52 | ) : null} 53 | {post.nextSlug ? ( 54 | 55 | Next → 56 | 57 | ) : null} 58 |
59 |
60 | 61 |
62 | 63 | ); 64 | } 65 | 66 | export async function getStaticProps({ params }) { 67 | const post = await getLesson(params.section, params.slug); 68 | return { 69 | props: { 70 | post, 71 | }, 72 | }; 73 | } 74 | 75 | export async function getStaticPaths() { 76 | const sections = await getLessons(); 77 | const lessons = sections.map((section) => section.lessons); 78 | const slugs = lessons.flat().map((lesson) => lesson.fullSlug); 79 | 80 | return { paths: slugs, fallback: false }; 81 | } 82 | -------------------------------------------------------------------------------- /public/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/.nojekyll -------------------------------------------------------------------------------- /public/ReelingIt.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/ReelingIt.zip -------------------------------------------------------------------------------- /public/images/Landing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/Landing.png -------------------------------------------------------------------------------- /public/images/SQL_Joins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/SQL_Joins.png -------------------------------------------------------------------------------- /public/images/add-new-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/add-new-server.png -------------------------------------------------------------------------------- /public/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/author.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/author.jpg -------------------------------------------------------------------------------- /public/images/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/config.png -------------------------------------------------------------------------------- /public/images/connection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/connection.png -------------------------------------------------------------------------------- /public/images/course-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/course-icon.png -------------------------------------------------------------------------------- /public/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/favicon.ico -------------------------------------------------------------------------------- /public/images/social-share-cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/btholt/complete-intro-to-sql/2e0de393af531a16c2300752eb2d5a69f1a7be87/public/images/social-share-cover.jpg -------------------------------------------------------------------------------- /styles/courses.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap"); 2 | 3 | /* mini css reset */ 4 | html { 5 | font-size: 16px; 6 | } 7 | 8 | body, 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6, 15 | p, 16 | ol, 17 | ul { 18 | margin: 0; 19 | padding: 0; 20 | font-weight: normal; 21 | } 22 | 23 | img { 24 | max-width: 100%; 25 | height: auto; 26 | } 27 | 28 | * { 29 | box-sizing: border-box; 30 | } 31 | 32 | body { 33 | font-family: "Open Sans"; 34 | background: linear-gradient(90deg, var(--bg-main) 15px, transparent 1%) center, 35 | linear-gradient(var(--bg-main) 15px, transparent 1%) center, var(--bg-dots); 36 | background-size: 16px 16px; 37 | margin: 0; 38 | } 39 | 40 | a { 41 | color: var(--text-links); 42 | text-decoration: none; 43 | } 44 | 45 | .navbar { 46 | border-bottom: 1px solid #ccc; 47 | position: fixed; 48 | width: 100%; 49 | top: 0; 50 | z-index: 10; 51 | display: flex; 52 | justify-content: space-between; 53 | align-items: center; 54 | background-color: var(--bg-main); 55 | padding: 10px; 56 | } 57 | 58 | .navbar h1 { 59 | font-size: 20px; 60 | margin: inherit; 61 | padding: inherit; 62 | font-weight: bold; 63 | color: var(--text-main); 64 | } 65 | 66 | .navbar h2 { 67 | font-size: 14px; 68 | margin: inherit; 69 | margin-left: 15px; 70 | padding: inherit; 71 | text-transform: uppercase; 72 | } 73 | 74 | .navbar-info { 75 | display: flex; 76 | flex-direction: row; 77 | align-items: center; 78 | justify-content: center; 79 | } 80 | 81 | header .cta-btn { 82 | display: none; /* only displays at large screen sizes */ 83 | } 84 | 85 | .main .cta-btn { 86 | width: 90%; 87 | margin: 20px auto 0px auto; 88 | max-width: 500px; 89 | padding: 12px 20px; 90 | } 91 | 92 | .cta-btn { 93 | border-radius: 10px; 94 | background: var(--nav-buttons); 95 | color: var(--nav-buttons-text); 96 | padding: 7px 20px; 97 | display: flex; 98 | justify-content: center; 99 | align-items: center; 100 | } 101 | 102 | .jumbotron { 103 | padding: 0; 104 | } 105 | 106 | .jumbotron .courseInfo, 107 | .jumbotron .courseIcon { 108 | padding: 20px; 109 | } 110 | 111 | .jumbotron .courseInfo, 112 | .jumbotron .courseIcon { 113 | text-align: center; 114 | } 115 | 116 | .author { 117 | margin-top: 40px; 118 | display: flex; 119 | justify-content: center; 120 | } 121 | 122 | @media (min-width: 1000px) { 123 | header .cta-btn { 124 | display: flex; 125 | } 126 | 127 | .main .cta-btn { 128 | display: none; 129 | } 130 | 131 | .jumbotron { 132 | display: flex; 133 | width: 100%; 134 | min-height: 45vh; 135 | } 136 | .jumbotron .courseInfo, 137 | .jumbotron .courseIcon { 138 | display: flex; 139 | justify-content: center; 140 | align-items: center; 141 | } 142 | .jumbotron .courseInfo { 143 | width: 65%; 144 | text-align: right; 145 | } 146 | .jumbotron .courseIcon { 147 | width: 35%; 148 | display: flex; 149 | align-items: center; 150 | justify-content: center; 151 | } 152 | 153 | .author { 154 | justify-content: flex-end; 155 | } 156 | .jumbotron .courseInfo-inner { 157 | max-width: 85%; 158 | } 159 | } 160 | 161 | .jumbotron h1, 162 | .jumbotron h2 { 163 | color: var(--text-main-headers); 164 | } 165 | 166 | .jumbotron h1 { 167 | font-size: 50px; 168 | margin-bottom: 20px; 169 | } 170 | 171 | .jumbotron .courseInfo { 172 | background: var(--primary); 173 | } 174 | 175 | .jumbotron .courseIcon { 176 | background: var(--secondary); 177 | } 178 | 179 | .jumbotron .courseIcon img { 180 | max-width: 180px; 181 | } 182 | 183 | .author .info { 184 | padding: 10px; 185 | } 186 | 187 | .author .name { 188 | font-size: 18px; 189 | font-weight: bold; 190 | color: var(--text-main-headers); 191 | } 192 | 193 | .author .company { 194 | color: var(--text-main-headers); 195 | font-size: 16px; 196 | } 197 | 198 | .author .image { 199 | border-radius: 75px; 200 | overflow: hidden; 201 | height: 75px; 202 | width: 75px; 203 | } 204 | 205 | .navbar-brand.navbar-brand a { 206 | text-transform: uppercase; 207 | font-weight: bold; 208 | color: var(--text-main-headers); 209 | } 210 | 211 | .lesson-section-title { 212 | color: var(--text-main-headers); 213 | } 214 | 215 | .lesson-container { 216 | position: relative; 217 | max-width: 900px; 218 | margin: 0 auto 45px auto; 219 | padding: 10px 40px; 220 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); 221 | background-color: var(--bg-lesson); 222 | border-radius: 5px; 223 | margin-top: 40px; 224 | } 225 | 226 | .lesson { 227 | margin: 15px; 228 | padding: 15px; 229 | border-radius: 8px; 230 | } 231 | 232 | .lesson > h1 { 233 | color: var(--text-header); 234 | font-size: 24px; 235 | } 236 | 237 | .lesson h2 { 238 | font-size: 24px; 239 | margin-top: 20px; 240 | margin-bottom: 10px; 241 | } 242 | 243 | .lesson h2::after { 244 | content: ""; 245 | display: block; 246 | height: 3px; 247 | margin-top: 5px; 248 | background: var(--text-header); 249 | max-width: 300px; 250 | } 251 | 252 | .lesson p { 253 | clear: both; 254 | } 255 | 256 | .lesson p, 257 | .lesson li { 258 | line-height: 180%; 259 | } 260 | 261 | .lesson li li { 262 | margin-left: 25px; 263 | } 264 | 265 | .lesson-links { 266 | font-size: 18px; 267 | padding: 15px 0; 268 | } 269 | 270 | .next { 271 | float: right; 272 | } 273 | 274 | .prev { 275 | float: left; 276 | } 277 | 278 | .lesson-title { 279 | text-transform: uppercase; 280 | font-weight: bold; 281 | } 282 | 283 | .gatsby-highlight { 284 | padding: 4px; 285 | border-radius: 4px; 286 | display: flex; 287 | justify-content: space-between; 288 | flex-direction: column; 289 | align-items: stretch; 290 | } 291 | 292 | .lesson-content td { 293 | border: 1px solid black; 294 | padding: 8px; 295 | } 296 | 297 | .lesson-content td input { 298 | min-width: 300px; 299 | } 300 | 301 | .lesson-content img { 302 | margin: 5px auto; 303 | display: block; 304 | } 305 | 306 | .lesson-flex { 307 | display: flex; 308 | flex-direction: column; 309 | justify-content: center; 310 | align-items: center; 311 | } 312 | 313 | .random-tweet { 314 | width: 100%; 315 | margin-top: 100px; 316 | } 317 | 318 | .fem-link { 319 | text-align: center; 320 | } 321 | 322 | .content-container { 323 | display: flex; 324 | flex-direction: column; 325 | justify-content: space-between; 326 | min-height: 100vh; 327 | padding-top: 50px; 328 | } 329 | 330 | blockquote { 331 | padding: 15px; 332 | background-color: var(--emphasized-bg); 333 | border: 2px solid var(--emphasized-border); 334 | border-radius: 5px; 335 | width: 100%; 336 | margin: 10px 0; 337 | } 338 | 339 | blockquote > *:last-child { 340 | margin-bottom: 0; 341 | } 342 | 343 | .lesson-content img { 344 | max-width: 100%; 345 | } 346 | 347 | .main-card { 348 | max-width: 900px; 349 | margin: 0 auto; 350 | overflow: hidden; 351 | } 352 | 353 | .lesson-title { 354 | font-size: 20px; 355 | padding: 15px 30px; 356 | } 357 | 358 | .lesson-content { 359 | line-height: 1.5; 360 | } 361 | 362 | .lesson-text { 363 | width: 100%; 364 | padding: 25px 5px 25px 35px; 365 | min-height: 200px; 366 | } 367 | 368 | .sections-name { 369 | margin: 0; 370 | padding: 0; 371 | } 372 | 373 | ol.sections-name { 374 | counter-reset: my-awesome-counter; 375 | list-style: none; 376 | padding-left: 40px; 377 | width: 98%; 378 | margin: 0; 379 | padding: 0; 380 | } 381 | 382 | ol.sections-name > li { 383 | counter-increment: my-awesome-counter; 384 | display: flex; 385 | flex-direction: row; 386 | flex-wrap: wrap; 387 | margin-bottom: 35px; 388 | width: 100%; 389 | box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.2); 390 | border-bottom-right-radius: 5px; 391 | border-top-right-radius: 5px; 392 | } 393 | ol.sections-name .lesson-preface { 394 | font-size: 100px; 395 | color: var(--icons); 396 | position: relative; 397 | align-items: center; 398 | justify-content: center; 399 | background: var(--primary); 400 | display: flex; 401 | padding: 25px; 402 | width: 30%; 403 | } 404 | 405 | .lesson-preface.lesson-preface > svg { 406 | width: 80%; 407 | height: inherit; 408 | max-height: 100px; 409 | } 410 | 411 | ol.sections-name .lesson-preface::before { 412 | content: counter(my-awesome-counter); 413 | position: absolute; 414 | top: 0; 415 | left: 5px; 416 | font-size: 20px; 417 | font-weight: bold; 418 | color: var(--icons); 419 | } 420 | 421 | ol.sections-name .lesson-details { 422 | display: flex; 423 | flex-basis: 100%; 424 | flex: 1; 425 | background: var(--bg-lesson); 426 | position: relative; 427 | } 428 | 429 | .details-bg { 430 | --corner-fill: var(--corner-inactive); 431 | transition: fill 0.25s; 432 | width: 10%; 433 | height: 0; 434 | padding-bottom: 10%; 435 | background-size: cover; 436 | background-repeat: no-repeat; 437 | position: absolute; 438 | top: 0; 439 | right: 0; 440 | } 441 | 442 | .details-bg > svg { 443 | width: 100%; 444 | height: auto; 445 | } 446 | 447 | .details-bg > svg path { 448 | transition: fill 0.25s; 449 | } 450 | 451 | .lesson-details:hover .details-bg, 452 | .lesson-container .details-bg { 453 | --corner-fill: var(--corner-active); 454 | } 455 | 456 | @media (min-width: 1000px) { 457 | ol.sections-name > li::before { 458 | border-bottom-left-radius: 5px; 459 | border-top-left-radius: 5px; 460 | } 461 | ol.sections-name .lesson-details { 462 | border-bottom-right-radius: 5px; 463 | border-top-right-radius: 5px; 464 | } 465 | } 466 | 467 | @media (max-width: 600px) { 468 | .lesson-container { 469 | padding: 2px; 470 | } 471 | 472 | ol.sections-name .lesson-preface { 473 | font-size:60px 474 | } 475 | } 476 | 477 | .lesson-details h3 { 478 | font-size: 22px; 479 | border-bottom: 1px solid var(--less); 480 | padding-bottom: 10px; 481 | display: inline-block; 482 | font-weight: bold; 483 | margin-bottom: 20px; 484 | } 485 | 486 | .lesson-links { 487 | margin-top: 45px; 488 | margin-bottom: 80px; 489 | } 490 | 491 | .lesson-links a { 492 | border-radius: 10px; 493 | background: var(--nav-buttons); 494 | color: var(--nav-buttons-text); 495 | padding: 15px 20px; 496 | display: inline-block; 497 | display: flex; 498 | justify-content: center; 499 | align-items: center; 500 | } 501 | 502 | .lesson-links a.prev { 503 | padding-left: 10px; 504 | } 505 | 506 | .lesson-links a.next { 507 | padding-right: 10px; 508 | } 509 | 510 | .lesson-links a:hover { 511 | background: #152837; 512 | text-decoration: none; 513 | } 514 | 515 | .lesson-links .arrow { 516 | font-size: 24px; 517 | line-height: 24px; 518 | padding: 0px 5px; 519 | } 520 | -------------------------------------------------------------------------------- /styles/footer.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | width: 100%; 3 | padding: 50px 15px; 4 | background-color: var(--primary); 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | color: var(--text-footer); 9 | } 10 | 11 | .socials { 12 | display: flex; 13 | align-items: center; 14 | max-width: 900px; 15 | width: 100%; 16 | margin: 0; 17 | padding: 0; 18 | } 19 | 20 | .social { 21 | display: inline-block; 22 | list-style: none; 23 | margin-right: 40px; 24 | } 25 | 26 | .social img:hover { 27 | opacity: 0.4; 28 | } 29 | 30 | .social img { 31 | transition: opacity 0.25s; 32 | width: 30px; 33 | } 34 | 35 | .terms { 36 | font-size: 10px; 37 | } 38 | 39 | .terms p { 40 | margin: 3px; 41 | } 42 | 43 | .footer a { 44 | color: inherit; 45 | text-decoration: underline; 46 | } 47 | 48 | .social svg { 49 | transition: opacity 0.25s; 50 | } 51 | 52 | .social svg:hover { 53 | opacity: 0.4; 54 | } 55 | -------------------------------------------------------------------------------- /styles/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: #ff6838; 3 | --secondary: #005c83; 4 | --highlight: #00263f; 5 | 6 | --text-header: var(--primary); 7 | --text-main-headers: var(--highlight); 8 | --text-links: var(--primary); 9 | --text-footer: #333; 10 | 11 | --bg-main: white; 12 | --bg-dots: var(--highlight); 13 | --bg-lesson: white; 14 | 15 | --nav-buttons: var(--highlight); 16 | --nav-buttons-text: white; 17 | 18 | --corner-active: var(--highlight); 19 | --corner-inactive: #f4f4f4; 20 | --icons: var(--highlight); 21 | --footer-icons: var(--highlight); 22 | 23 | --emphasized-bg: #dce8ff; 24 | --emphasized-border: #aab6d2; 25 | } 26 | --------------------------------------------------------------------------------