diff --git a/.vitepress/lib/cds-playground/md-live-code.ts b/.vitepress/lib/cds-playground/md-live-code.ts index 9725bf8f7..4322f3e0e 100644 --- a/.vitepress/lib/cds-playground/md-live-code.ts +++ b/.vitepress/lib/cds-playground/md-live-code.ts @@ -115,11 +115,15 @@ export function install(md: MarkdownRenderer) { } const { info } = tokens[idx] - const [language, live, ...rest] = info.split(' ') + const hlMatch = info.match(/\{[\d,\-]+\}/) + const highlightSpec = hlMatch?.[0] ?? '' + const infoNormalized = info.replace(/\s*\{[\d,\-]+\}/, '') + const [language, live, ...rawRest] = infoNormalized.split(' ') // Suppress named CSV data blocks marked hidden — content is captured in the pre-pass and shown as a model tab. if (language === 'csv' && live === 'hidden' && /\[[^\]]+:[^\]]+\]/.test(info)) return '' + const rest = rawRest.map(flag => flag.replace(/^\[|\]$/g, '')) // e.g. "[async]" -> "async" if (live === 'live') { const mdDir = dirname(env.realPath ?? env.path) const filePath = './' + relative(mdDir, join(__dirname, '../../theme/components/cds-playground/LiveCode.vue')) @@ -131,7 +135,7 @@ export function install(md: MarkdownRenderer) { return idx > -1 ? [key, rest.splice(idx+1, 1)[0]] : []; })) - const modelArg = rest.find((p: string) => MODEL_ARG_RE.test(p)) + const modelArg = rawRest.find((p: string) => MODEL_ARG_RE.test(p)) const modelName = modelArg ? modelArg.slice(1, -1) : null const modelDef: ModelDef | undefined = modelName ? (env as any)._modelMap[modelName] : undefined @@ -140,8 +144,9 @@ export function install(md: MarkdownRenderer) { } if (modelDef?.source) props.modelSource = md.utils.escapeHtml(modelDef.source) if (modelDef?.csvs) props.modelData = md.utils.escapeHtml(JSON.stringify(modelDef.csvs)) + if (highlightSpec) props.highlightLines = highlightSpec - const flags = ['readonly'].filter(k => rest.includes(k)) + const flags = ['readonly', 'async'].filter(k => rest.includes(k)) const content = tokens[idx].content.trim() return ` `${k}="${v}"`).join(' ')} ${flags.join(' ')}>` diff --git a/.vitepress/theme/components/cds-playground/LiveCode.vue b/.vitepress/theme/components/cds-playground/LiveCode.vue index 479897be8..b49f68bc0 100644 --- a/.vitepress/theme/components/cds-playground/LiveCode.vue +++ b/.vitepress/theme/components/cds-playground/LiveCode.vue @@ -5,7 +5,7 @@
{{ props.language === 'cds'? 'cql' : props.language }} - +
@@ -14,6 +14,7 @@ @@ -77,6 +78,7 @@ import play from '/icons/play.svg?url&raw' import { runners, runWithModel } from './runners' import highlighter from './highlighter' import templates from 'virtual:templates' +import { transformerMetaHighlight } from '@shikijs/transformers' const uid = useId() @@ -91,6 +93,10 @@ const props = defineProps({ type: Boolean, default: false }, + async: { + type: Boolean, + default: false + }, language: { type: String, default: 'js' @@ -103,6 +109,10 @@ const props = defineProps({ type: String, default: '' }, + highlightLines: { + type: String, + default: '' + }, onEvaluate: { type: Function } @@ -156,14 +166,18 @@ function toggleModel() { } } -function format({ value, kind }, dark) { +function format({ value, kind }, dark, highlightSpec = '') { if (!highlighter.getLoadedLanguages().includes(kind)) { kind = 'plaintext' } - const html = highlighter.codeToHtml( + const opts = { lang: kind, theme: dark ? 'github-dark' : 'github-light', transformers: [], meta: undefined } + if (highlightSpec) { + opts.meta = { __raw: highlightSpec } + opts.transformers = [transformerMetaHighlight()] + } + return highlighter.codeToHtml( typeof value === 'string' ? value : JSON.stringify(value, null, 2), - { lang: kind, theme: dark ? 'github-dark' : 'github-light' }) - return html + opts) } function formatTabs(result) { @@ -220,7 +234,7 @@ async function evaluate() { const exec = props.onEvaluate ?? (props.modelSource ? (q) => runWithModel(q, props.modelSource, props.modelData ? JSON.parse(props.modelData) : undefined) : runners[props.language]) if (!exec) throw new Error(`No runner found for language: ${props.language}. Available runners: ${Object.keys(runners).join(', ')}`) - const result = await exec(queryText.value) + const result = await exec(queryText.value, props.async) tabs.value = formatTabs(result).filter(({ value }) => value) if (!tabs.value.map(tab => tab.key).includes(selectedTab.value)) selectedTab.value = tabs.value[0].key diff --git a/.vitepress/theme/components/cds-playground/MonacoEditor.vue b/.vitepress/theme/components/cds-playground/MonacoEditor.vue index 98d5a6577..6d16244bd 100644 --- a/.vitepress/theme/components/cds-playground/MonacoEditor.vue +++ b/.vitepress/theme/components/cds-playground/MonacoEditor.vue @@ -26,6 +26,10 @@ const props = defineProps({ rows: { type: Number, default: 3 + }, + highlightLines: { + type: String, + default: '' } }) @@ -86,6 +90,17 @@ async function createEditor() { try { const contentSizeDispose = editor.onDidContentSizeChange(() => updateHeight()) updateHeight() + if (props.highlightLines) { + const lines = props.highlightLines.replace(/^\{|\}$/g, '').split(',').flatMap(part => { + const [a, b] = part.trim().split('-').map(Number) + return b ? Array.from({ length: b - a + 1 }, (_, i) => a + i) : [a] + }) + editor.createDecorationsCollection(lines.map(line => ({ + range: new monaco.Range(line, 1, line, 1), + options: { isWholeLine: true, className: 'live-code-highlighted-line' } + }))) + } + // Emit evaluate on Cmd/Ctrl+Enter editor.addAction({ id: 'eval', @@ -155,4 +170,8 @@ watch(() => isDark.value, (dark) => { background-color: var(--vp-code-block-bg) !important; font-family: var(--vp-font-family-mono) !important; } + +.live-code-highlighted-line { + background-color: var(--vp-code-line-highlight-color) !important; +} diff --git a/.vitepress/theme/components/cds-playground/highlighter.js b/.vitepress/theme/components/cds-playground/highlighter.js index 5dce6261c..381d52e43 100644 --- a/.vitepress/theme/components/cds-playground/highlighter.js +++ b/.vitepress/theme/components/cds-playground/highlighter.js @@ -3,7 +3,7 @@ import languages from '../../../languages' const highlighter = await createHighlighter({ themes: ['github-dark', 'github-light'], - langs: ['javascript', 'js', 'sql', 'typescript', 'vue', ...languages], + langs: ['javascript', 'js', 'sql', 'typescript', 'vue', 'yaml', ...languages], langAlias: Object.fromEntries(languages.flatMap(l => { if (!l || typeof l !== 'object' || !Array.isArray(l.aliases) || !l.name) return [] return l.aliases.map(alias => [alias, l.name]) diff --git a/.vitepress/theme/components/cds-playground/runners.js b/.vitepress/theme/components/cds-playground/runners.js index c383391cc..93d241368 100644 --- a/.vitepress/theme/components/cds-playground/runners.js +++ b/.vitepress/theme/components/cds-playground/runners.js @@ -27,7 +27,7 @@ function injectLogger(sqlite) { return sqlLog; } - +/** @returns {Promise} */ async function initialize() { const cds = (await import('@sap/cds')).default; const express = (await import('express')).default; @@ -61,6 +61,7 @@ async function initialize() { return cds; } +/** @type {ReturnType} */ let initialized; if (!import.meta.env.SSR) { // runs only in the browser @@ -68,15 +69,40 @@ if (!import.meta.env.SSR) { } const AsyncFunction = async function () {}.constructor; -async function evalJS(code) { - await initialized; - const fn = new AsyncFunction(code); - const { result, formatted } = await sql.trace(fn); +async function evalJS(code, isAsync) { + const cds = await initialized; + const source = compile(code); + + function resultTabs(result, kind) { + if (kind === 'json') { + let yaml + try { yaml = cds.compile.to.yaml(result) } catch {/* ignore */} + if (yaml) return [ + { value: yaml, kind: 'yaml', name: 'Result (as yaml)' }, + { value: JSON.stringify(result, null, 2), kind: 'json', name: 'Result (raw)' }, + ] + } + return [{ value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' }] + } + + if (isAsync) { + let fn; + try { fn = new AsyncFunction(source) } + catch { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified + const { result, formatted } = await sql.trace(fn); + const kind = result? 'json' : 'plaintext' + return [ + ...resultTabs(result, kind), + { value: formatted, kind: 'sql', name: 'SQL'} + ]; + } + + let fn; + try { fn = new Function(source) } + catch { fn = new Function(code) } // rewrite had a syntax error -> run the code unmodified + const result = fn(); const kind = result? 'json' : 'plaintext' - return [ - { value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' }, - { value: formatted, kind: 'sql', name: 'SQL'} - ]; + return resultTabs(result, kind); } async function cdsQL(query) { @@ -138,3 +164,103 @@ export const runners = { cql: cdsQL, cds: cdsQL, } + +function compile(code) { + const stmts = splitTopLevelStatements(code) + if (!stmts.length) return code + const last = stmts[stmts.length - 1] + + // last statement already returns, or is a control-flow/declaration keyword -> leave the code as is + if (/^(return|throw|if|for|while|function|class|import|export)\b/.test(last.text)) return code + + // anchored right after the keyword so we don't match "=" occurring inside the initializer, e.g. in a template literal + const declRe = /^(?:let|const|var)\s+([A-Za-z_$][\w$]*)\s*=/ + if (declRe.test(last.text)) { + // last statement declares a variable, e.g. "let result = 1+1" -> collect all top-level declarations in the + // snippet so earlier ones aren't silently dropped, e.g. comparing "let q = ...; let p = ..." side by side + const names = stmts.map(s => s.text.match(declRe)?.[1]).filter(Boolean) + return names.length > 1 + ? `${code}\nreturn { ${names.join(', ')} };` + : `${code}\nreturn ${names[0]};` + } + + // last statement isn't a declaration -> treat it (possibly spanning multiple lines) as the expression to return + return `${code.slice(0, last.start)}\nreturn (\n${last.text.replace(/;\s*$/, '')}\n);` +} + + +// splits code into its top-level statements (ignoring newlines/semicolons nested inside brackets, strings, +// template literals or comments), so multi-line statements like object literals are kept intact as one unit +function splitTopLevelStatements(code) { + const scrubbed = blankComments(code) // same length as code, but with comments replaced by spaces + const stmts = [] + let start = 0, depth = 0, i = 0 + while (i < scrubbed.length) { + const c = scrubbed[i] + if (c === '"' || c === "'") { i = skipString(scrubbed, i, c); continue } + if (c === '`') { i = skipTemplate(scrubbed, i); continue } + if (c === '(' || c === '{' || c === '[') { depth++; i++; continue } + if (c === ')' || c === '}' || c === ']') { depth--; i++; continue } + if (depth <= 0 && (c === ';' || c === '\n')) { + const text = scrubbed.slice(start, i).trim() + if (text) stmts.push({ text, start }) + i++; start = i; continue + } + i++ + } + const text = scrubbed.slice(start).trim() + if (text) stmts.push({ text, start }) + return stmts +} + +// replaces line and block comments with spaces of the same length, so a trailing comment (e.g. after the last +// statement, or commented-out code on its own line) is never mistaken for code, while offsets stay unchanged +function blankComments(code) { + let out = '' + let i = 0 + while (i < code.length) { + const c = code[i] + if (c === '/' && code[i + 1] === '/') { while (i < code.length && code[i] !== '\n') { out += ' '; i++ }; continue } + if (c === '/' && code[i + 1] === '*') { + while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) { out += code[i] === '\n' ? '\n' : ' '; i++ } + out += ' '; i += 2; continue + } + if (c === '"' || c === "'") { const j = skipString(code, i, c); out += code.slice(i, j); i = j; continue } + if (c === '`') { const j = skipTemplate(code, i); out += code.slice(i, j); i = j; continue } + out += c; i++ + } + return out +} + +// skips a single- or double-quoted string starting at code[i], returning the index right after the closing quote +function skipString(code, i, quote) { + i++ + while (i < code.length && code[i] !== quote) { if (code[i] === '\\') i++; i++ } + return i + 1 +} + +// skips a template literal starting at code[i] (the opening backtick), diving into ${...} interpolations +function skipTemplate(code, i) { + i++ + while (i < code.length) { + if (code[i] === '\\') { i += 2; continue } + if (code[i] === '`') return i + 1 + if (code[i] === '$' && code[i + 1] === '{') { i = skipBraces(code, i + 2); continue } + i++ + } + return i +} + +// skips forward to the '}' balancing the '${' whose contents start at code[i] +function skipBraces(code, i) { + let depth = 1 + while (i < code.length && depth > 0) { + const c = code[i] + if (c === '"' || c === "'") { i = skipString(code, i, c); continue } + else if (c === '`') { i = skipTemplate(code, i); continue } + else if (c === '{') depth++ + else if (c === '}') depth-- + i++ + } + return i +} diff --git a/cds/cdl.md b/cds/cdl.md index 9487f7826..e4ffc07e4 100644 --- a/cds/cdl.md +++ b/cds/cdl.md @@ -147,8 +147,7 @@ Within those strings, escape sequences from JavaScript, such as `\t` or `\u0020` Using directives allow to import definitions from other CDS models. As shown in line 3 below, you optionally can specify local aliases to be used subsequently. You can import single definitions as well as several ones with a common namespace prefix. -::: code-group - +```cds using foo.bar.scoped.Bar from './contexts'; using foo.bar.scoped.nested from './contexts'; using foo.bar.scoped.nested as animal from './contexts'; @@ -158,8 +157,6 @@ entity Moo : nested.Zoo {} //> : foo.bar.scoped.nested.Zoo entity Zoo : animal.Zoo {} //> : foo.bar.scoped.nested.Zoo ``` -::: - Multiple named imports through ES6-like deconstructors: ```cds diff --git a/cds/cxl.md b/cds/cxl.md index 41936fe71..9c7208104 100644 --- a/cds/cxl.md +++ b/cds/cxl.md @@ -44,7 +44,7 @@ The cds model initialized on this page is a slightly modified version of the [ca All samples run on a single browser-local `cds` instance, you can access it via the dev tools or run statements in the following code block: -```js live +```js live [async] await INSERT.into('Books').entries( { ID: 2, author_ID: 150, title: 'Eldorado' } ) diff --git a/node.js/cds-compile.md b/node.js/cds-compile.md index 7c2ae8f40..e493580e3 100644 --- a/node.js/cds-compile.md +++ b/node.js/cds-compile.md @@ -54,7 +54,7 @@ let csn = await cds.compile ('file:db') > The given filenames are resolved to effective absolute filenames using [`cds.resolve`](#cds-resolve). > [!TIP] Use cds compile as CLI equivalent -> The [`cds compile` CLI](../tools/cds-cli#cds-compile) is available as entry point to the functions described here. For example, `cds compile --to hana` maps to `cds.compile.to.hana` etc. +> The [`cds compile` CLI](../tools/cds-cli#cds-compile) is available as entry point to the functions described here. For example, `cds compile --to hana` maps to [`cds.compile.to.hana`](#hana) etc. @@ -62,34 +62,35 @@ let csn = await cds.compile ('file:db') If a single string, not starting with `file:` is passed as first argument, it is interpreted as a CDL source string and compiled to CSN synchronously: -```js +```js live let csn = cds.compile (` - using {cuid} from '@sap/cds/common'; - entity Foo : cuid { foo:String } + entity Foo { foo:String } entity Bar as projection on Foo; extend Foo with { bar:String } `) ``` -> Note: `using from` clauses are not resolved in this usage. - +> [!note] `using from` clauses are not resolved in this usage. +> In this example, there is an error at the line where [`cuid`](../cds/common#aspect-cuid) gets used: +> +> ```js live +> let csn = cds.compile (` +> using { cuid } from '@sap/cds/common'; +> entity Foo : cuid { foo:String } +> `) +> ``` ### Multiple in-memory sources -Finally, you can pass an object with multiple named CDL or CSN sources, which allows to also resolve `using from` clauses: +Finally, you can pass an object with multiple named CDL or CSN sources, which allows to also resolve [`using from` clauses](../cds/cdl#model-imports): -```js +```js live {3,6} let csn = cds.compile ({ 'db/schema.cds': ` - using {cuid} from '@sap/cds/common'; + using { cuid } from '@sap/cds/common'; entity Foo : cuid { foo:String } `, - 'srv/services.cds': ` - using {Foo} from '../db/schema'; - entity Bar as projection on Foo; - extend Foo with { bar:String } - `, '@sap/cds/common.csn': ` {"definitions":{ "cuid": { "kind": "aspect", "elements": { @@ -100,20 +101,30 @@ let csn = cds.compile ({ }) ``` - - +> [!tip] Reference imported models with canonic names +> In the example, note that the `@sap/cds/common.csn` source is referenced through the canonic `@sap/cds/common` name. +> From the usage perspective, it should not matter if imported definitions are defined as [CDL](../cds/cdl) or [CSN](../cds/csn) and what their technical (file) name is. +> +> [Learn more on CDS model resolution.](../cds/cdl#model-resolution){.learn-more} ### Additional Options You can pass additional options like so: -```js -let csn = await cds.compile('*',{ min:true, docs:true }) +```js live +let messages = [] +let csn = cds.compile(` + /** A comment about */ + /** entity Foo */ + entity Foo { foo:String } + entity Bar as projection on Foo; + type T { e:String } + `, + { min:true, flavor:'parsed', docs:true, locations:true, messages } +) ``` - - | Option | Description | | ----------- | ------------------------------------------------------------ | | `flavor` | By default the returned CSN is in `'inferred'` flavor, which is an effective model, with all aspects, includes, extensions and redirects applied and all views and projections inferred. Specify `'parsed'` to only have single models parsed. | @@ -122,15 +133,22 @@ let csn = await cds.compile('*',{ min:true, docs:true }) | `locations` | Specify `true` to have the all `$location` properties preserved in serialized CSN. | | `messages` | Pass an empty array to get all compiler messages collected in there. | - +Run the example. See that: +- `messages` is filled with a compiler warning about the double comment, +- `docs:true` leads to a `doc` field in the CSN for entity `Foo`, +- `min:true` discards the unused type `T`, +- `flavor:'parsed'` does not resolve `Bar`'s elements from `Foo`. ## cds. compile .to ... {.property} Following are a collection of model processors which take a CSN as input and compile it to a target output. They can be used in two API flavors: -```js +```js live +let csn = {definitions:{}} let sql = cds.compile(csn).to.sql ({dialect:'sqlite'}) //> fluent +``` +```js live let sql = cds.compile.to.sql (csn,{dialect:'sqlite'}) //> direct ``` @@ -189,6 +207,7 @@ for (let [edm,{file,suffix}] of all) Use [`cds.compile.to.hana`](#hana) instead. ### .hana() {.method} +###### hana Generates `hdbtable/hdbview` output. diff --git a/node.js/cds-ql.md b/node.js/cds-ql.md index 67d92763e..05896fce9 100644 --- a/node.js/cds-ql.md +++ b/node.js/cds-ql.md @@ -18,29 +18,28 @@ Module `cds.ql` provides facilities to construct queries in [*Core Query Notatio 1. Fluent API style, with query-by-example objects for where clauses and order by clauses: -```js +```js live let q = SELECT.from('Books').where({ID:201}).orderBy({title:1}) ``` 2. Using with [tagged template literals (TTL)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates): -```js +```js live let q = cds.ql `SELECT from Books where ID=${201} order by title` ``` 3. Fluent API with interspersed [tagged template literals (TTL)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates): -```js +```js live let q = SELECT.from `Books where ID=${201} order by title` let p = SELECT.from `Books`.where`ID=${201}`.orderBy`title` ``` 4. Manually constructing CQN objects: -```js +```js live const { expr, ref, val, columns, expand, where, orderBy } = cds.ql -``` -```js + let q = { SELECT: { from: ref`Books`, @@ -49,7 +48,9 @@ let q = { } } ``` -```js +```js live +const { expr, ref, val, columns, expand, where, orderBy } = cds.ql + let q = { SELECT: { from: ref`Authors`, @@ -78,7 +79,7 @@ const { SELECT, INSERT, UPDATE, DELETE } = cds.ql It is recommended best practice to use entity definitions reflected from a service's model to construct queries. Doing so simplifies code as it avoids repeating namespaces all over the place. -```js +```js live const { Books } = cds.entities let q1 = SELECT.from (Books) .where `ID=${201}` ``` @@ -96,30 +97,32 @@ While both [CQL](../cds/cql) / [CQN](../cds/cqn) as well as the fluent API of `c Queries are executed by passing them to a service's [`srv.run()`](core-services#srv-run-query) method, for example, to the primary database: -```js +```js live [async] let query = SELECT `ID,title` .from `Books` let books = await cds.db.run (query) ``` Alternatively, you can just `await` a constructed query, which by default passes the query to `cds.db.run()`. So, the following is equivalent to the above: -```js +```js live [async] let books = await SELECT `ID,title` .from `Books` ``` Instead of a database service, you can also send queries to other services, local or remote ones. For example: -```js +```js live [async] const cats = await cds.connect.to ('CatalogService') +let query = SELECT `ID,title` .from `Books` let books = await cats.run (query) +return {query, books} ``` > `CatalogService` might be a remote service connected via OData. In this case, the query would be translated to an OData request sent via HTTP. The APIs are also available through [`cds.Service`'s CRUD-style Convenience API](core-services#crud-style-api), for example: -```js +```js live [async] const db = cds.db -let books = await db.read`Books`.where`ID=${201}`.orderBy`title` +await db.read`Books`.where`ID=${201}`.orderBy`title` ``` @@ -128,10 +131,10 @@ let books = await db.read`Books`.where`ID=${201}`.orderBy`title` Constructing queries doesn't execute them immediately, but just captures the given query information. Very much like functions in JavaScript, queries are first-class objects, which can be assigned to variables, modified, passed as arguments, or returned from functions. Let's investigate this somewhat more, given this example: -```js -let cats = await cds.connect.to('CatalogService') //> connected via OData -let PoesBooks = SELECT.from (Books) .where `name like '%Poe%'` -let books = await cats.get (PoesBooks) +```js live [async] +cats = await cds.connect.to('CatalogService')//> connected via OData +PoesBooks = SELECT.from ('Books') .where `author like '%Poe%'` +books = await cats.get (PoesBooks) ``` This is what happens behind the scenes: @@ -150,24 +153,26 @@ This is what happens behind the scenes: You can also combine queries much like sub selects in SQL to form more complex queries as shown in this example: -```sql +```js live [async] let input = '%Brontë%' let Authors = SELECT `ID` .from `Authors` .where `name like ${ input }` let Books = SELECT.from `Books` .where `author_ID in ${ Authors }` -``` -```js await cds.run (Books) //> late/no materialization of Authors +// TODO fails with 'Authors not found' ``` With that we leverage late materialization, offered by SQL databases. Compare that to inferior imperative programming: -```js +```js live [async] let input = '%Brontë%' let Authors = await SELECT `ID` .from `Authors` .where `name like ${ input }` -for (let a of Authors) { //> looping over eagerly materialized Authors - let Books = await SELECT.from `Books` .where `author_ID = ${ a.ID }` -} +// looping over eagerly materialized Auxthors +let books = [] +for (let a of Authors) { books.push ( + ...await SELECT.from `Books` .where `author_ID = ${ a.ID }` +)} +return books ```