diff --git a/eleventy.config.js b/eleventy.config.js index 0a6c26c..04325ea 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -2,6 +2,8 @@ const eleventyNavigationPlugin = require("@11ty/eleventy-navigation"); const eleventySyntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight"); const eleventySass = require("eleventy-sass"); const mdDefList = require("markdown-it-deflist"); +const mdToc = require("markdown-it-table-of-contents"); +const mdAnchor = require("markdown-it-anchor"); const eleventyRss = require("@11ty/eleventy-plugin-rss"); module.exports = function (eleventyConfig) { @@ -24,7 +26,12 @@ module.exports = function (eleventyConfig) { loadPaths: ["node_modules"], }, }); - eleventyConfig.amendLibrary("md", mdLib => mdLib.use(mdDefList)); + eleventyConfig.amendLibrary("md", mdLib => mdLib + .use(mdDefList) + .use(mdToc, { + includeLevel: [ 1, 2, 3, 4, 5, 6 ] + }) + .use(mdAnchor)); eleventyConfig.addFilter("IsNotPage", (collection, url) => collection.filter(item => item.url != url)); diff --git a/package-lock.json b/package-lock.json index a74228a..4592377 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@11ty/eleventy-plugin-rss": "^1.2.0", "feather-icons": "^4.29.0", + "markdown-it-anchor": "^8.6.7", + "markdown-it-table-of-contents": "^0.6.0", "normalize.css": "^8.0.1", "prism-themes": "^1.9.0" }, @@ -298,6 +300,28 @@ "node": ">=8" } }, + "node_modules/@types/linkify-it": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", + "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", + "peer": true + }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "peer": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", + "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", + "peer": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -1411,7 +1435,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", - "dev": true, "dependencies": { "uc.micro": "^1.0.1" } @@ -1481,7 +1504,6 @@ "version": "13.0.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", "integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", - "dev": true, "dependencies": { "argparse": "^2.0.1", "entities": "~3.0.1", @@ -1493,17 +1515,33 @@ "markdown-it": "bin/markdown-it.js" } }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, "node_modules/markdown-it-deflist": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/markdown-it-deflist/-/markdown-it-deflist-2.1.0.tgz", "integrity": "sha512-3OuqoRUlSxJiuQYu0cWTLHNhhq2xtoSFqsZK8plANg91+RJQU1ziQ6lA2LzmFAEes18uPBsHZpcX6We5l76Nzg==", "dev": true }, + "node_modules/markdown-it-table-of-contents": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/markdown-it-table-of-contents/-/markdown-it-table-of-contents-0.6.0.tgz", + "integrity": "sha512-jHvEjZVEibyW97zEYg19mZCIXO16lHbvRaPDkEuOfMPBmzlI7cYczMZLMfUvwkhdOVQpIxu3gx6mgaw46KsNsQ==", + "engines": { + "node": ">6.4.0" + } + }, "node_modules/markdown-it/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/maximatch": { "version": "0.1.0", @@ -1553,8 +1591,7 @@ "node_modules/mdurl": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", - "dev": true + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" }, "node_modules/merge2": { "version": "1.4.1", @@ -2353,8 +2390,7 @@ "node_modules/uc.micro": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", - "dev": true + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, "node_modules/uglify-js": { "version": "3.15.3", @@ -2654,6 +2690,28 @@ } } }, + "@types/linkify-it": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", + "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", + "peer": true + }, + "@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "peer": true, + "requires": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "@types/mdurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", + "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", + "peer": true + }, "@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -3479,7 +3537,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", - "dev": true, "requires": { "uc.micro": "^1.0.1" } @@ -3531,7 +3588,6 @@ "version": "13.0.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", "integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", - "dev": true, "requires": { "argparse": "^2.0.1", "entities": "~3.0.1", @@ -3543,17 +3599,27 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" } } }, + "markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "requires": {} + }, "markdown-it-deflist": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/markdown-it-deflist/-/markdown-it-deflist-2.1.0.tgz", "integrity": "sha512-3OuqoRUlSxJiuQYu0cWTLHNhhq2xtoSFqsZK8plANg91+RJQU1ziQ6lA2LzmFAEes18uPBsHZpcX6We5l76Nzg==", "dev": true }, + "markdown-it-table-of-contents": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/markdown-it-table-of-contents/-/markdown-it-table-of-contents-0.6.0.tgz", + "integrity": "sha512-jHvEjZVEibyW97zEYg19mZCIXO16lHbvRaPDkEuOfMPBmzlI7cYczMZLMfUvwkhdOVQpIxu3gx6mgaw46KsNsQ==" + }, "maximatch": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", @@ -3592,8 +3658,7 @@ "mdurl": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", - "dev": true + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" }, "merge2": { "version": "1.4.1", @@ -4204,8 +4269,7 @@ "uc.micro": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", - "dev": true + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, "uglify-js": { "version": "3.15.3", diff --git a/package.json b/package.json index 66d3400..2343d0c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "dependencies": { "@11ty/eleventy-plugin-rss": "^1.2.0", "feather-icons": "^4.29.0", + "markdown-it-anchor": "^8.6.7", + "markdown-it-table-of-contents": "^0.6.0", "normalize.css": "^8.0.1", "prism-themes": "^1.9.0" } diff --git a/src/css/Components/_card.scss b/src/css/Components/_card.scss index 61e6b0b..9e6ee82 100644 --- a/src/css/Components/_card.scss +++ b/src/css/Components/_card.scss @@ -4,12 +4,13 @@ display: flex; flex-direction: column; padding: 1rem; - margin-bottom: 1rem; + margin-top: 0.5rem; + margin-bottom: 0.5rem; border: solid 1px var(--primary-border-color); border-radius: var(--main-border-radius); h1, h2, h3, h4, h5, h6 { - margin-bottom: 0; + margin: 0; } .card-links { diff --git a/src/css/Components/_code.scss b/src/css/Components/_code.scss index 5c10228..4bed014 100644 --- a/src/css/Components/_code.scss +++ b/src/css/Components/_code.scss @@ -12,6 +12,7 @@ $code-background-color-dark: lighten(variables.$background-color-dark, 5%); } code { + font-size: 0.9em; background: var(--code-background); padding: 0.125em 0.25em; border: solid 1px var(--primary-border-color); @@ -19,6 +20,7 @@ code { } pre { + font-size: 0.9em; background: var(--code-background); padding: 1em; border: solid 1px var(--primary-border-color); @@ -30,6 +32,11 @@ pre { border: unset; border-radius: unset; } + + &[class*=language-] { + font-size: 0.9em; + margin-bottom: 1em; + } } .token.keyword { diff --git a/src/css/Components/_headings.scss b/src/css/Components/_headings.scss index 01f21c1..d5906c4 100644 --- a/src/css/Components/_headings.scss +++ b/src/css/Components/_headings.scss @@ -1,8 +1,9 @@ h1, h2, h3, h4, h5, h6 { font-weight: 500; - margin-top: 0; + text-transform: capitalize; } h1 { font-size: 3em; + margin-top: 0; } diff --git a/src/css/Components/_table-of-contents.scss b/src/css/Components/_table-of-contents.scss new file mode 100644 index 0000000..540247d --- /dev/null +++ b/src/css/Components/_table-of-contents.scss @@ -0,0 +1,10 @@ +@use '_variables'; + +.table-of-contents { + padding-left: 0.5em; + padding-right: 1em; + border: 1px solid var(--primary-border-color); + border-radius: var(--main-border-radius); + + text-transform: capitalize; +} diff --git a/src/css/site.scss b/src/css/site.scss index fdfd2bb..882e465 100644 --- a/src/css/site.scss +++ b/src/css/site.scss @@ -12,3 +12,4 @@ @use 'Components/_lists'; @use 'Components/_code'; @use 'Components/_posts'; +@use 'Components/_table-of-contents'; diff --git a/src/posts/PluggableQueries.md b/src/posts/PluggableQueries.md new file mode 100644 index 0000000..8f956b1 --- /dev/null +++ b/src/posts/PluggableQueries.md @@ -0,0 +1,167 @@ +--- +title: Pluggable Expressions In Entity Framework +description: The application structure I've been using as an alternative to the repository pattern +tags: [ Programming, C#, Entity Framework ] +--- + +[[toc]] + +After maintaining a mid-sized application using a repository pattern, I've found this method to be more flexible and easier to test. + +## Pluggable expressions + +Expressions can be saved to variables (or returned by properties or functions) just like any object and just referenced in a query: + +```csharp +Expression> registrationIsOpen = + t => t.RegistrationOpenDate < DateTime.Now + && t.RegistrationCloseDate > DateTime.Now; + +IList openThings = db.Things + .Where(registrationIsOpen) + .ToList(); +``` + +This also has the benefit of being very declarative; it's pretty easy to tell what the query is doing with `.Where(registrationIsOpen)` at a glance, even if you don't know much about `Thing` or the database structure. This becomes more of an advantage the more complicated the filter is. + +These are also easy to test. Just create an in-memory list, run the expression against the list, and verify the results. Like with all EF queries though some expressions can't be translated, so you need to watch out for that. + +You can have static classes filled with a bunch of pre-built expressions that you can pick from and apply as needed. If you have a one-off filter you need to use, then you can just directly use a lambda instead of a pre-built expression. + +This can also be used on anything that `Where` can be called on. So if you already have a list, you can just reuse the same expression in a LINQ query on that list. Another useful case for this is map functions. Just like you can create expressions to pass to `.Where()`, you can also create expressions to pass to `.Select()`: + +```csharp +public class ThingViewModel { + public int ThingId { get; set; } + public string ThingName { get; set; } + public DateTime RegistrationOpenDate { get; set; } + public DateTime RegistrationCloseDate { get; set; } + ... + + public static Expression> Map() { + return t => new ThingViewModel { + ThingId = t.ThingId, + ThingName = t.ThingName, + RegistrationOpenDate = t.RegistrationOpenDate, + RegistrationCloseDate = t.RegistrationCloseDate, + ... + }; + } +} +``` + +Then to use it: + +```csharp +IList things = db.Things + .Select(ThingViewModel.Map()) + .ToList(); +``` + +## Nested expressions + +EF doesn't actually execute the LINQ query - it uses reflection to pick the query apart and translate it into a SQL query. Because of this, EF can only translate function calls and expressions it recognizes. This becomes a problem when you have nested expressions like this: + +```csharp +Expression> isPastRegistrationOpenDate = + t => t.RegistrationOpenDate < DateTime.Now; +Expression> isBeforeRegistrationCloseDate = + t => t.RegistrationCloseDate > DateTime.Now; +Expression> registrationIsOpen = + t => isPastRegistrationOpenDate.Invoke(t) + && isBeforeRegistrationCloseDate.Invoke(t); + +IList openThings = db.Things + .Where(registrationIsOpen) + .ToList(); +``` + +This query will fail because Entity Framework can't doesn't have a translation for `isPastRegistrationOpenDate` and `isBeforeRegistrationCloseDate`. + +In this case, the `registrationIsOpen` in `.Where(registrationIsOpen)` is replaced by the actual expression and EF will read the actual underlying expression. However, what EF sees when it tries to translate the query is references to two variables and an `Invoke` function that it doesn't understand. + +The workaround for this is to use [LINQKit's `.AsExpandable()`](https://github.com/scottksmith95/LINQKit#plugging-expressions-into-entitysets--entitycollections-the-solution). By adding this to the query, it will alter the query and replace ("expand") the references to expressions with the expressions themselves. Then EF will see expressions that it understands. + +So unlike the query above, this will work: + +```csharp +Expression> isPastRegistrationOpenDate = + t => t.RegistrationOpenDate < DateTime.Now; +Expression> isBeforeRegistrationCloseDate = + t => t.RegistrationCloseDate > DateTime.Now; +Expression> registrationIsOpen = + t => isPastRegistrationOpenDate.Invoke(t) + && isBeforeRegistrationCloseDate.Invoke(t); + +IList openThings = db.Things + .AsExpandable() + .Where(registrationIsOpen) + .ToList(); +``` + +And that's it. Just add `.AsExpandable()` to your query and it will work. + +Another example using the above view model example: + +```csharp +public static class ThingExpressions { + public static Expression> IsPastRegistrationOpenDate() { + return t => t.RegistrationOpenDate < DateTime.Now; + } + + public static Expression> IsBeforeRegistrationCloseDate() { + return t => t.RegistrationCloseDate > DateTime.Now; + } + + public static Expression> RegistrationIsOpen() { + return t => IsPastRegistrationOpenDate.Invoke(t) + && IsBeforeRegistrationCloseDate.Invoke(t); + } +} + +public class ThingViewModel { + public int ThingId { get; set; } + public string ThingName { get; set; } + public bool IsRegistrationOpen { get; set; } + + public static Expression> Map() { + return t => new ThingViewModel { + ThingId = t.ThingId, + ThingName = t.ThingName, + // This will throw an exception if AsExpandable isn't used + IsRegistrationOpen = ThingExpressions.RegistrationIsOpen().Invoke(t) + }; + } +} +``` + +Then to use it: + +```csharp +IList things = db.Things + .AsExpandable() + .Select(ThingViewModel.Map()) + .ToList(); +``` + +## Compared to repositories + +The usual way to centralize filters for database queries with Entity Framework is to use repositories with methods like this: + +```csharp +public IList GetThings(bool whereRegistrationOpen = false) { + var query = this.db.Things + .AsQueryable(); + + if (whereRegistrationOpen) + query = query + .Where(t => t.RegistrationOpenDate < DateTime.Now) + .Where(t => t.RegistrationCloseDate > DateTime.Now); + + return query.ToList(); +} +``` + +However, for large, complex objects this can quickly bloat the function with dozens of options. This makes the method very large (potentially hundreds of lines) and more awkward to ensure that tests each only test a specific piece of the method. + +Also, the filters themselves are sealed in the repository method. You can't use them in a different way than the repository is built for (like applying the filter to an in-memory list or using them to set a property in a `.Select()`).