@mistriwork/engine 0.0.0-stage → 1.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/adapters/adapter.d.ts +693 -0
- package/dist/adapters/adapter.js +444 -0
- package/dist/adapters/cache/cacheStore.d.ts +75 -0
- package/dist/adapters/cache/cacheStore.js +160 -0
- package/dist/adapters/cache/resp.d.ts +52 -0
- package/dist/adapters/cache/resp.js +237 -0
- package/dist/adapters/commands/canonicalJson.d.ts +19 -0
- package/dist/adapters/commands/canonicalJson.js +154 -0
- package/dist/adapters/connect.d.ts +29 -0
- package/dist/adapters/connect.js +40 -0
- package/dist/adapters/connectionBudget.d.ts +101 -0
- package/dist/adapters/connectionBudget.js +128 -0
- package/dist/adapters/dbLatency.d.ts +35 -0
- package/dist/adapters/dbLatency.js +79 -0
- package/dist/adapters/epochCache.d.ts +50 -0
- package/dist/adapters/epochCache.js +85 -0
- package/dist/adapters/http/egress.d.ts +48 -0
- package/dist/adapters/http/egress.js +175 -0
- package/dist/adapters/http/httpAdapter.d.ts +71 -0
- package/dist/adapters/http/httpAdapter.js +512 -0
- package/dist/adapters/http/httpDescription.d.ts +32 -0
- package/dist/adapters/http/httpDescription.js +246 -0
- package/dist/adapters/http/openapi.d.ts +98 -0
- package/dist/adapters/http/openapi.js +487 -0
- package/dist/adapters/layoutRefusal.d.ts +24 -0
- package/dist/adapters/layoutRefusal.js +35 -0
- package/dist/adapters/net/networkPolicy.d.ts +44 -0
- package/dist/adapters/net/networkPolicy.js +123 -0
- package/dist/adapters/net/untrustedDatabase.d.ts +18 -0
- package/dist/adapters/net/untrustedDatabase.js +120 -0
- package/dist/adapters/poolAdmission.d.ts +43 -0
- package/dist/adapters/poolAdmission.js +184 -0
- package/dist/adapters/poolMetrics.d.ts +121 -0
- package/dist/adapters/poolMetrics.js +185 -0
- package/dist/adapters/postgres.d.ts +436 -0
- package/dist/adapters/postgres.js +1890 -0
- package/dist/adapters/postgresCatalog.d.ts +96 -0
- package/dist/adapters/postgresCatalog.js +387 -0
- package/dist/adapters/postgresCatalogDigest.d.ts +30 -0
- package/dist/adapters/postgresCatalogDigest.js +87 -0
- package/dist/adapters/postgresCatalogRows.d.ts +62 -0
- package/dist/adapters/postgresCatalogRows.js +37 -0
- package/dist/adapters/postgresDates.d.ts +1 -0
- package/dist/adapters/postgresDates.js +39 -0
- package/dist/adapters/postgresMaintenance.d.ts +33 -0
- package/dist/adapters/postgresMaintenance.js +179 -0
- package/dist/adapters/postgresPool.d.ts +255 -0
- package/dist/adapters/postgresPool.js +838 -0
- package/dist/adapters/postgresPooler.d.ts +28 -0
- package/dist/adapters/postgresPooler.js +267 -0
- package/dist/adapters/postgresReadCatalog.d.ts +31 -0
- package/dist/adapters/postgresReadCatalog.js +226 -0
- package/dist/adapters/postgresReadSnapshot.d.ts +46 -0
- package/dist/adapters/postgresReadSnapshot.js +133 -0
- package/dist/adapters/postgresSchemaEpoch.d.ts +24 -0
- package/dist/adapters/postgresSchemaEpoch.js +149 -0
- package/dist/adapters/postgresTransactionHandle.d.ts +173 -0
- package/dist/adapters/postgresTransactionHandle.js +524 -0
- package/dist/adapters/postgresValues.d.ts +30 -0
- package/dist/adapters/postgresValues.js +75 -0
- package/dist/adapters/replicas.d.ts +70 -0
- package/dist/adapters/replicas.js +208 -0
- package/dist/adapters/reservedSql.d.ts +38 -0
- package/dist/adapters/reservedSql.js +264 -0
- package/dist/adapters/rolePrivileges.d.ts +17 -0
- package/dist/adapters/rolePrivileges.js +22 -0
- package/dist/adapters/scoped/keyRows.d.ts +65 -0
- package/dist/adapters/scoped/keyRows.js +173 -0
- package/dist/adapters/scoped/postgresScopeCatalog.d.ts +16 -0
- package/dist/adapters/scoped/postgresScopeCatalog.js +117 -0
- package/dist/adapters/scoped/scopedCommandOperations.d.ts +5 -0
- package/dist/adapters/scoped/scopedCommandOperations.js +48 -0
- package/dist/adapters/scoped/scopedReads.d.ts +20 -0
- package/dist/adapters/scoped/scopedReads.js +174 -0
- package/dist/adapters/scoped/scopedTransaction.d.ts +89 -0
- package/dist/adapters/scoped/scopedTransaction.js +303 -0
- package/dist/adapters/scoped/sqliteScopeCatalog.d.ts +16 -0
- package/dist/adapters/scoped/sqliteScopeCatalog.js +241 -0
- package/dist/adapters/scoped/updateGuard.d.ts +3 -0
- package/dist/adapters/scoped/updateGuard.js +40 -0
- package/dist/adapters/serverSettings.d.ts +45 -0
- package/dist/adapters/serverSettings.js +46 -0
- package/dist/adapters/sqlite.d.ts +262 -0
- package/dist/adapters/sqlite.js +1434 -0
- package/dist/adapters/sqliteCostlyReads.d.ts +33 -0
- package/dist/adapters/sqliteCostlyReads.js +49 -0
- package/dist/adapters/sqliteDecimals.d.ts +22 -0
- package/dist/adapters/sqliteDecimals.js +57 -0
- package/dist/adapters/sqliteHandles.d.ts +43 -0
- package/dist/adapters/sqliteHandles.js +143 -0
- package/dist/adapters/sqliteStatementProcess.d.ts +29 -0
- package/dist/adapters/sqliteStatementProcess.js +131 -0
- package/dist/adapters/statementControl.d.ts +34 -0
- package/dist/adapters/statementControl.js +90 -0
- package/dist/adapters/storeHost.d.ts +130 -0
- package/dist/adapters/storeHost.js +13 -0
- package/dist/adapters/telemetry/drivers.d.ts +21 -0
- package/dist/adapters/telemetry/drivers.js +321 -0
- package/dist/adapters/telemetry/metrics.d.ts +63 -0
- package/dist/adapters/telemetry/metrics.js +122 -0
- package/dist/adapters/telemetry/otlp.d.ts +12 -0
- package/dist/adapters/telemetry/otlp.js +320 -0
- package/dist/adapters/telemetry/otlpEncode.d.ts +48 -0
- package/dist/adapters/telemetry/otlpEncode.js +366 -0
- package/dist/adapters/telemetry/otlpEnv.d.ts +1 -0
- package/dist/adapters/telemetry/otlpEnv.js +12 -0
- package/dist/adapters/telemetry/serverTiming.d.ts +83 -0
- package/dist/adapters/telemetry/serverTiming.js +503 -0
- package/dist/adapters/telemetry/start.d.ts +10 -0
- package/dist/adapters/telemetry/start.js +25 -0
- package/dist/adapters/telemetry/statement.d.ts +16 -0
- package/dist/adapters/telemetry/statement.js +134 -0
- package/dist/adapters/telemetry/trace.d.ts +127 -0
- package/dist/adapters/telemetry/trace.js +295 -0
- package/dist/adapters/writeAhead.d.ts +27 -0
- package/dist/adapters/writeAhead.js +46 -0
- package/dist/adapters/writeAttribution.d.ts +18 -0
- package/dist/adapters/writeAttribution.js +23 -0
- package/dist/adapters/writeCapture.d.ts +73 -0
- package/dist/adapters/writeCapture.js +20 -0
- package/dist/adapters/writeLanes.d.ts +94 -0
- package/dist/adapters/writeLanes.js +232 -0
- package/dist/engine.d.ts +15 -0
- package/dist/engine.js +13 -0
- package/dist/server/auth/credentials.d.ts +61 -0
- package/dist/server/auth/credentials.js +260 -0
- package/dist/server/cacheService.d.ts +50 -0
- package/dist/server/cacheService.js +110 -0
- package/dist/server/cli/args.d.ts +176 -0
- package/dist/server/cli/args.js +342 -0
- package/dist/server/cli/bin.d.ts +5 -0
- package/dist/server/cli/bin.js +66 -0
- package/dist/server/cli/browser.d.ts +15 -0
- package/dist/server/cli/browser.js +37 -0
- package/dist/server/cli/config.d.ts +50 -0
- package/dist/server/cli/config.js +206 -0
- package/dist/server/cli/startup.d.ts +36 -0
- package/dist/server/cli/startup.js +128 -0
- package/dist/server/cli/startupOptions.d.ts +63 -0
- package/dist/server/cli/startupOptions.js +143 -0
- package/dist/server/cli/subcommand.d.ts +28 -0
- package/dist/server/cli/subcommand.js +32 -0
- package/dist/server/cli/usage.d.ts +13 -0
- package/dist/server/cli/usage.js +31 -0
- package/dist/server/clientHttp.d.ts +184 -0
- package/dist/server/clientHttp.js +700 -0
- package/dist/server/clientSurface.d.ts +128 -0
- package/dist/server/clientSurface.js +182 -0
- package/dist/server/columnGrants.d.ts +103 -0
- package/dist/server/columnGrants.js +730 -0
- package/dist/server/compileCache.d.ts +1 -0
- package/dist/server/compileCache.js +30 -0
- package/dist/server/config/locations.d.ts +1 -0
- package/dist/server/config/locations.js +15 -0
- package/dist/server/configPaths.d.ts +7 -0
- package/dist/server/configPaths.js +34 -0
- package/dist/server/configSections.d.ts +64 -0
- package/dist/server/configSections.js +72 -0
- package/dist/server/connectionRedaction.d.ts +30 -0
- package/dist/server/connectionRedaction.js +77 -0
- package/dist/server/connections.d.ts +353 -0
- package/dist/server/connections.js +978 -0
- package/dist/server/constraints.d.ts +82 -0
- package/dist/server/constraints.js +169 -0
- package/dist/server/demo.d.ts +9 -0
- package/dist/server/demo.js +554 -0
- package/dist/server/devclient.d.ts +108 -0
- package/dist/server/devclient.js +361 -0
- package/dist/server/evidence.d.ts +55 -0
- package/dist/server/evidence.js +323 -0
- package/dist/server/hiddenColumns.d.ts +32 -0
- package/dist/server/hiddenColumns.js +238 -0
- package/dist/server/http/baselineHeaders.d.ts +24 -0
- package/dist/server/http/baselineHeaders.js +12 -0
- package/dist/server/http/captured.d.ts +35 -0
- package/dist/server/http/captured.js +73 -0
- package/dist/server/http/clock.d.ts +1 -0
- package/dist/server/http/clock.js +14 -0
- package/dist/server/http/connectionSource.d.ts +4 -0
- package/dist/server/http/connectionSource.js +48 -0
- package/dist/server/http/context.d.ts +189 -0
- package/dist/server/http/context.js +11 -0
- package/dist/server/http/crawlerText.d.ts +15 -0
- package/dist/server/http/crawlerText.js +32 -0
- package/dist/server/http/dataRoutes.d.ts +88 -0
- package/dist/server/http/dataRoutes.js +137 -0
- package/dist/server/http/door.d.ts +57 -0
- package/dist/server/http/door.js +39 -0
- package/dist/server/http/exportRoute.d.ts +15 -0
- package/dist/server/http/exportRoute.js +320 -0
- package/dist/server/http/firstPaint.d.ts +108 -0
- package/dist/server/http/firstPaint.js +262 -0
- package/dist/server/http/httpOperationRoutes.d.ts +11 -0
- package/dist/server/http/httpOperationRoutes.js +50 -0
- package/dist/server/http/pageReads.d.ts +20 -0
- package/dist/server/http/pageReads.js +205 -0
- package/dist/server/http/pageRoutes.d.ts +39 -0
- package/dist/server/http/pageRoutes.js +291 -0
- package/dist/server/http/probes.d.ts +22 -0
- package/dist/server/http/probes.js +27 -0
- package/dist/server/http/rateLimit.d.ts +131 -0
- package/dist/server/http/rateLimit.js +351 -0
- package/dist/server/http/readRoutes.d.ts +9 -0
- package/dist/server/http/readRoutes.js +131 -0
- package/dist/server/http/readRouting.d.ts +20 -0
- package/dist/server/http/readRouting.js +80 -0
- package/dist/server/http/recordCompose.d.ts +67 -0
- package/dist/server/http/recordCompose.js +167 -0
- package/dist/server/http/recordWrites.d.ts +89 -0
- package/dist/server/http/recordWrites.js +298 -0
- package/dist/server/http/refusalReport.d.ts +38 -0
- package/dist/server/http/refusalReport.js +63 -0
- package/dist/server/http/reply.d.ts +130 -0
- package/dist/server/http/reply.js +394 -0
- package/dist/server/http/requestOrigin.d.ts +11 -0
- package/dist/server/http/requestOrigin.js +8 -0
- package/dist/server/http/router.d.ts +45 -0
- package/dist/server/http/router.js +22 -0
- package/dist/server/http/serverCore.d.ts +68 -0
- package/dist/server/http/serverCore.js +177 -0
- package/dist/server/http/traceRequest.d.ts +30 -0
- package/dist/server/http/traceRequest.js +224 -0
- package/dist/server/http/trust.d.ts +261 -0
- package/dist/server/http/trust.js +472 -0
- package/dist/server/http/writeRefusal.d.ts +33 -0
- package/dist/server/http/writeRefusal.js +24 -0
- package/dist/server/httpConnection.d.ts +49 -0
- package/dist/server/httpConnection.js +170 -0
- package/dist/server/impact.d.ts +55 -0
- package/dist/server/impact.js +47 -0
- package/dist/server/journal.d.ts +134 -0
- package/dist/server/journal.js +398 -0
- package/dist/server/keysetReadHttp.d.ts +52 -0
- package/dist/server/keysetReadHttp.js +85 -0
- package/dist/server/layouts.d.ts +49 -0
- package/dist/server/layouts.js +192 -0
- package/dist/server/migrate/cli.d.ts +55 -0
- package/dist/server/migrate/cli.js +517 -0
- package/dist/server/migrate/database.d.ts +37 -0
- package/dist/server/migrate/database.js +247 -0
- package/dist/server/migrate/desired.d.ts +76 -0
- package/dist/server/migrate/desired.js +292 -0
- package/dist/server/migrate/files.d.ts +34 -0
- package/dist/server/migrate/files.js +141 -0
- package/dist/server/migrate/flyway.d.ts +7 -0
- package/dist/server/migrate/flyway.js +69 -0
- package/dist/server/migrate/history.d.ts +70 -0
- package/dist/server/migrate/history.js +211 -0
- package/dist/server/migrate/runner.d.ts +100 -0
- package/dist/server/migrate/runner.js +409 -0
- package/dist/server/migrate/snapshot.d.ts +30 -0
- package/dist/server/migrate/snapshot.js +239 -0
- package/dist/server/migrate/target.d.ts +33 -0
- package/dist/server/migrate/target.js +82 -0
- package/dist/server/openapiDraft.d.ts +25 -0
- package/dist/server/openapiDraft.js +171 -0
- package/dist/server/operations/diagnostics.d.ts +101 -0
- package/dist/server/operations/diagnostics.js +284 -0
- package/dist/server/operations/requestMetrics.d.ts +33 -0
- package/dist/server/operations/requestMetrics.js +94 -0
- package/dist/server/packageVersion.d.ts +1 -0
- package/dist/server/packageVersion.js +11 -0
- package/dist/server/poolConfig.d.ts +34 -0
- package/dist/server/poolConfig.js +114 -0
- package/dist/server/product.d.ts +20 -0
- package/dist/server/product.js +12 -0
- package/dist/server/recordHistoryOperations.d.ts +39 -0
- package/dist/server/recordHistoryOperations.js +43 -0
- package/dist/server/recordWriteOperations.d.ts +111 -0
- package/dist/server/recordWriteOperations.js +329 -0
- package/dist/server/resourceReadHttp.d.ts +81 -0
- package/dist/server/resourceReadHttp.js +606 -0
- package/dist/server/resourceReadOperations.d.ts +147 -0
- package/dist/server/resourceReadOperations.js +452 -0
- package/dist/server/scaffoldMachine.d.ts +54 -0
- package/dist/server/scaffoldMachine.js +184 -0
- package/dist/server/seed/cli.d.ts +20 -0
- package/dist/server/seed/cli.js +164 -0
- package/dist/server/seed/lexicon.d.ts +26 -0
- package/dist/server/seed/lexicon.js +585 -0
- package/dist/server/seed/seed.d.ts +70 -0
- package/dist/server/seed/seed.js +779 -0
- package/dist/server/seed/words.d.ts +21 -0
- package/dist/server/seed/words.js +164 -0
- package/dist/server/serverRequests.d.ts +6 -0
- package/dist/server/serverRequests.js +33 -0
- package/dist/server/serverTimeouts.d.ts +33 -0
- package/dist/server/serverTimeouts.js +32 -0
- package/dist/server/shapeonly.d.ts +32 -0
- package/dist/server/shapeonly.js +110 -0
- package/dist/server/startupWarnings.d.ts +32 -0
- package/dist/server/startupWarnings.js +92 -0
- package/dist/shared/addressParams.d.ts +28 -0
- package/dist/shared/addressParams.js +47 -0
- package/dist/shared/appCards.d.ts +123 -0
- package/dist/shared/appCards.js +385 -0
- package/dist/shared/appComponents.d.ts +177 -0
- package/dist/shared/appComponents.js +422 -0
- package/dist/shared/appDocuments.d.ts +43 -0
- package/dist/shared/appDocuments.js +131 -0
- package/dist/shared/appEmbeds.d.ts +21 -0
- package/dist/shared/appEmbeds.js +24 -0
- package/dist/shared/appIcons.d.ts +28 -0
- package/dist/shared/appIcons.js +223 -0
- package/dist/shared/appLinks.d.ts +17 -0
- package/dist/shared/appLinks.js +124 -0
- package/dist/shared/appListColumns.d.ts +3 -0
- package/dist/shared/appListColumns.js +66 -0
- package/dist/shared/appLocale.d.ts +36 -0
- package/dist/shared/appLocale.js +86 -0
- package/dist/shared/appMessages.d.ts +44 -0
- package/dist/shared/appMessages.js +78 -0
- package/dist/shared/appNoun.d.ts +21 -0
- package/dist/shared/appNoun.js +32 -0
- package/dist/shared/autoBucket.d.ts +98 -0
- package/dist/shared/autoBucket.js +305 -0
- package/dist/shared/autoValues.d.ts +25 -0
- package/dist/shared/autoValues.js +25 -0
- package/dist/shared/breakdown.d.ts +228 -0
- package/dist/shared/breakdown.js +557 -0
- package/dist/shared/breakdowntext.d.ts +41 -0
- package/dist/shared/breakdowntext.js +400 -0
- package/dist/shared/builtInThemes.d.ts +2 -0
- package/dist/shared/builtInThemes.js +771 -0
- package/dist/shared/chartSeparation.d.ts +11 -0
- package/dist/shared/chartSeparation.js +58 -0
- package/dist/shared/clauses.d.ts +22 -0
- package/dist/shared/clauses.js +97 -0
- package/dist/shared/columnCaption.d.ts +16 -0
- package/dist/shared/columnCaption.js +90 -0
- package/dist/shared/commandFieldPath.d.ts +2 -0
- package/dist/shared/commandFieldPath.js +7 -0
- package/dist/shared/commandWire.d.ts +26 -0
- package/dist/shared/commandWire.js +41 -0
- package/dist/shared/computed.d.ts +161 -0
- package/dist/shared/computed.js +378 -0
- package/dist/shared/computedParse.d.ts +13 -0
- package/dist/shared/computedParse.js +278 -0
- package/dist/shared/computedValue.d.ts +47 -0
- package/dist/shared/computedValue.js +191 -0
- package/dist/shared/connection.d.ts +30 -0
- package/dist/shared/connection.js +1 -0
- package/dist/shared/csv.d.ts +20 -0
- package/dist/shared/csv.js +63 -0
- package/dist/shared/dashLayout.d.ts +30 -0
- package/dist/shared/dashLayout.js +132 -0
- package/dist/shared/dashboardAsks.d.ts +93 -0
- package/dist/shared/dashboardAsks.js +129 -0
- package/dist/shared/dashboardTrend.d.ts +20 -0
- package/dist/shared/dashboardTrend.js +50 -0
- package/dist/shared/declaredReferences.d.ts +50 -0
- package/dist/shared/declaredReferences.js +202 -0
- package/dist/shared/definedFields.d.ts +63 -0
- package/dist/shared/definedFields.js +103 -0
- package/dist/shared/definedValues.d.ts +63 -0
- package/dist/shared/definedValues.js +184 -0
- package/dist/shared/displayDefaults.d.ts +135 -0
- package/dist/shared/displayDefaults.js +529 -0
- package/dist/shared/dueDate.d.ts +6 -0
- package/dist/shared/dueDate.js +6 -0
- package/dist/shared/duration.d.ts +4 -0
- package/dist/shared/duration.js +23 -0
- package/dist/shared/fileBounds.d.ts +9 -0
- package/dist/shared/fileBounds.js +9 -0
- package/dist/shared/filter.d.ts +217 -0
- package/dist/shared/filter.js +305 -0
- package/dist/shared/firstPaint.d.ts +24 -0
- package/dist/shared/firstPaint.js +33 -0
- package/dist/shared/form.d.ts +21 -0
- package/dist/shared/form.js +25 -0
- package/dist/shared/formValues.d.ts +104 -0
- package/dist/shared/formValues.js +352 -0
- package/dist/shared/funnel.d.ts +45 -0
- package/dist/shared/funnel.js +108 -0
- package/dist/shared/httpOperations.d.ts +48 -0
- package/dist/shared/httpOperations.js +9 -0
- package/dist/shared/iconGlyphs.d.ts +4 -0
- package/dist/shared/iconGlyphs.js +151 -0
- package/dist/shared/iconWeights.d.ts +3 -0
- package/dist/shared/iconWeights.js +162 -0
- package/dist/shared/imageWidths.d.ts +32 -0
- package/dist/shared/imageWidths.js +38 -0
- package/dist/shared/keysetPage.d.ts +64 -0
- package/dist/shared/keysetPage.js +161 -0
- package/dist/shared/like.d.ts +20 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/listEmpty.d.ts +12 -0
- package/dist/shared/listEmpty.js +36 -0
- package/dist/shared/listFilters.d.ts +28 -0
- package/dist/shared/listFilters.js +84 -0
- package/dist/shared/listPresentations.d.ts +199 -0
- package/dist/shared/listPresentations.js +7 -0
- package/dist/shared/listQuestion.d.ts +52 -0
- package/dist/shared/listQuestion.js +137 -0
- package/dist/shared/lookPalettes.d.ts +62 -0
- package/dist/shared/lookPalettes.js +306 -0
- package/dist/shared/maskQuoted.d.ts +24 -0
- package/dist/shared/maskQuoted.js +48 -0
- package/dist/shared/mentionLabel.d.ts +19 -0
- package/dist/shared/mentionLabel.js +38 -0
- package/dist/shared/mentionLookup.d.ts +17 -0
- package/dist/shared/mentionLookup.js +64 -0
- package/dist/shared/menuCounts.d.ts +16 -0
- package/dist/shared/menuCounts.js +74 -0
- package/dist/shared/model.d.ts +79 -0
- package/dist/shared/model.js +153 -0
- package/dist/shared/money.d.ts +44 -0
- package/dist/shared/money.js +81 -0
- package/dist/shared/moneyEntry.d.ts +7 -0
- package/dist/shared/moneyEntry.js +27 -0
- package/dist/shared/mount.d.ts +118 -0
- package/dist/shared/mount.js +248 -0
- package/dist/shared/niceTicks.d.ts +26 -0
- package/dist/shared/niceTicks.js +50 -0
- package/dist/shared/order.d.ts +59 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.d.ts +135 -0
- package/dist/shared/page.js +1187 -0
- package/dist/shared/pageAnatomy.d.ts +99 -0
- package/dist/shared/pageAnatomy.js +370 -0
- package/dist/shared/pageConfig.d.ts +25 -0
- package/dist/shared/pageConfig.js +1308 -0
- package/dist/shared/pageEmptyActions.d.ts +12 -0
- package/dist/shared/pageEmptyActions.js +20 -0
- package/dist/shared/pageLayouts.d.ts +72 -0
- package/dist/shared/pageLayouts.js +205 -0
- package/dist/shared/pageModel.d.ts +785 -0
- package/dist/shared/pageModel.js +121 -0
- package/dist/shared/pagePaths.d.ts +180 -0
- package/dist/shared/pagePaths.js +869 -0
- package/dist/shared/pagePlans.d.ts +52 -0
- package/dist/shared/pagePlans.js +108 -0
- package/dist/shared/pageRoutes.d.ts +9 -0
- package/dist/shared/pageRoutes.js +9 -0
- package/dist/shared/query.d.ts +166 -0
- package/dist/shared/query.js +1302 -0
- package/dist/shared/queryDates.d.ts +12 -0
- package/dist/shared/queryDates.js +101 -0
- package/dist/shared/queryOperands.d.ts +99 -0
- package/dist/shared/queryOperands.js +131 -0
- package/dist/shared/reads.d.ts +103 -0
- package/dist/shared/reads.js +154 -0
- package/dist/shared/recordLabel.d.ts +51 -0
- package/dist/shared/recordLabel.js +123 -0
- package/dist/shared/recordReadKeys.d.ts +30 -0
- package/dist/shared/recordReadKeys.js +51 -0
- package/dist/shared/recordReads.d.ts +268 -0
- package/dist/shared/recordReads.js +368 -0
- package/dist/shared/recordview.d.ts +198 -0
- package/dist/shared/recordview.js +356 -0
- package/dist/shared/referenceModel.d.ts +58 -0
- package/dist/shared/referenceModel.js +194 -0
- package/dist/shared/refusal.d.ts +64 -0
- package/dist/shared/refusal.js +144 -0
- package/dist/shared/relatedCreate.d.ts +30 -0
- package/dist/shared/relatedCreate.js +162 -0
- package/dist/shared/resourceHttp.d.ts +464 -0
- package/dist/shared/resourceHttp.js +1 -0
- package/dist/shared/richText.d.ts +85 -0
- package/dist/shared/richText.js +305 -0
- package/dist/shared/rollups.d.ts +143 -0
- package/dist/shared/rollups.js +64 -0
- package/dist/shared/rowScope.d.ts +72 -0
- package/dist/shared/rowScope.js +376 -0
- package/dist/shared/runtimeTables.d.ts +44 -0
- package/dist/shared/runtimeTables.js +79 -0
- package/dist/shared/schema.d.ts +523 -0
- package/dist/shared/schema.js +627 -0
- package/dist/shared/schematext.d.ts +38 -0
- package/dist/shared/schematext.js +101 -0
- package/dist/shared/scopeCatalog.d.ts +29 -0
- package/dist/shared/scopeCatalog.js +468 -0
- package/dist/shared/servedApp.d.ts +681 -0
- package/dist/shared/servedApp.js +45 -0
- package/dist/shared/sessionLease.d.ts +34 -0
- package/dist/shared/sessionLease.js +3 -0
- package/dist/shared/shape.d.ts +55 -0
- package/dist/shared/shape.js +168 -0
- package/dist/shared/slug.d.ts +1 -0
- package/dist/shared/slug.js +12 -0
- package/dist/shared/sortText.d.ts +14 -0
- package/dist/shared/sortText.js +26 -0
- package/dist/shared/sqlRelation.d.ts +4 -0
- package/dist/shared/sqlRelation.js +8 -0
- package/dist/shared/sqlsaved.d.ts +44 -0
- package/dist/shared/sqlsaved.js +63 -0
- package/dist/shared/stitchedPaths.d.ts +27 -0
- package/dist/shared/stitchedPaths.js +38 -0
- package/dist/shared/structuredFilter.d.ts +22 -0
- package/dist/shared/structuredFilter.js +1 -0
- package/dist/shared/subtitlePath.d.ts +20 -0
- package/dist/shared/subtitlePath.js +24 -0
- package/dist/shared/suggest.d.ts +18 -0
- package/dist/shared/suggest.js +59 -0
- package/dist/shared/tableSizing.d.ts +4 -0
- package/dist/shared/tableSizing.js +9 -0
- package/dist/shared/tagLists.d.ts +57 -0
- package/dist/shared/tagLists.js +34 -0
- package/dist/shared/textRules.d.ts +54 -0
- package/dist/shared/textRules.js +133 -0
- package/dist/shared/theme.d.ts +94 -0
- package/dist/shared/theme.js +800 -0
- package/dist/shared/themeColor.d.ts +34 -0
- package/dist/shared/themeColor.js +109 -0
- package/dist/shared/themeFaces.d.ts +30 -0
- package/dist/shared/themeFaces.js +42 -0
- package/dist/shared/themeInk.d.ts +20 -0
- package/dist/shared/themeInk.js +52 -0
- package/dist/shared/themeLooks.d.ts +9 -0
- package/dist/shared/themeLooks.js +572 -0
- package/dist/shared/themeParts.d.ts +26 -0
- package/dist/shared/themeParts.js +71 -0
- package/dist/shared/themePartsCheck.d.ts +21 -0
- package/dist/shared/themePartsCheck.js +91 -0
- package/dist/shared/themePresets.d.ts +85 -0
- package/dist/shared/themePresets.js +139 -0
- package/dist/shared/themeRecipeWords.d.ts +156 -0
- package/dist/shared/themeRecipeWords.js +156 -0
- package/dist/shared/themeRecipes.d.ts +213 -0
- package/dist/shared/themeRecipes.js +234 -0
- package/dist/shared/themeResolve.d.ts +55 -0
- package/dist/shared/themeResolve.js +52 -0
- package/dist/shared/themeTokens.d.ts +107 -0
- package/dist/shared/themeTokens.js +116 -0
- package/dist/shared/themeType.d.ts +38 -0
- package/dist/shared/themeType.js +205 -0
- package/dist/shared/unitWord.d.ts +4 -0
- package/dist/shared/unitWord.js +37 -0
- package/dist/shared/view.d.ts +195 -0
- package/dist/shared/view.js +987 -0
- package/dist/shared/viewLabels.d.ts +47 -0
- package/dist/shared/viewLabels.js +459 -0
- package/dist/shared/viewPaths.d.ts +248 -0
- package/dist/shared/viewPaths.js +294 -0
- package/dist/shared/viewtext.d.ts +57 -0
- package/dist/shared/viewtext.js +282 -0
- package/dist/shared/vocabulary.d.ts +71 -0
- package/dist/shared/vocabulary.js +249 -0
- package/dist/shared/weekStart.d.ts +7 -0
- package/dist/shared/weekStart.js +36 -0
- package/mark.svg +1 -0
- package/package.json +261 -4
- package/schema/model.d.ts +298 -0
- package/schema/theme.d.ts +587 -0
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
import { MAX_LIMIT } from './reads.js';
|
|
2
|
+
import { LIKE_ESCAPE, likePattern } from './like.js';
|
|
3
|
+
import { distance, nearest } from './suggest.js';
|
|
4
|
+
import { columnKind, findTable } from './schema.js';
|
|
5
|
+
import { dateWindow } from './queryDates.js';
|
|
6
|
+
import { maskQuoted } from './maskQuoted.js';
|
|
7
|
+
import { ME_SET, columnOperand, meOperand, meSetOperand } from './queryOperands.js';
|
|
8
|
+
import { TAGS_TYPE } from './tagLists.js';
|
|
9
|
+
import { DEFINED_PATH_TYPE } from './definedFields.js';
|
|
10
|
+
const RANGE_OPS = new Set(['<', '<=', '>', '>=']);
|
|
11
|
+
export { dateWindow } from './queryDates.js';
|
|
12
|
+
/**
|
|
13
|
+
* A query's problem as an App's author reads it: the message, then where in
|
|
14
|
+
* the query it stands. The query bar underlines the word; a query written in
|
|
15
|
+
* an App's source is read in a terminal, where "character 23" and the words
|
|
16
|
+
* from there are what find it. `text` is what the positions count in, and the
|
|
17
|
+
* author's own words start at `from` (after a table name the caller added).
|
|
18
|
+
*/
|
|
19
|
+
export function saidAt(error, text, from = 0) {
|
|
20
|
+
const own = text.slice(from);
|
|
21
|
+
const at = error.at === undefined ? -1 : error.at - from;
|
|
22
|
+
if (at < 0 || at >= own.length)
|
|
23
|
+
return error.message;
|
|
24
|
+
const rest = own.slice(at).trimStart();
|
|
25
|
+
const start = own.length - rest.length;
|
|
26
|
+
return `${error.message} (at character ${start + 1}: "${rest.length > 32 ? `${rest.slice(0, 32)}…` : rest}")`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* What can appear in a field position.
|
|
30
|
+
*
|
|
31
|
+
* `\w`, plus `.` for reference paths, `$` because some dialects allow it in
|
|
32
|
+
* identifiers, and `+` for the composite-hop syntax a view uses:
|
|
33
|
+
* `invoice_id+line_no.sku` follows a two-column foreign key. Without `+`
|
|
34
|
+
* those hops were addressable only by their constraint name, which is a
|
|
35
|
+
* database artefact nobody wants to type.
|
|
36
|
+
*
|
|
37
|
+
* `+` is safe to admit because the language has no arithmetic — there is
|
|
38
|
+
* nothing it could otherwise have meant — and it cannot appear in a real
|
|
39
|
+
* column name, so it can never be ambiguous with one.
|
|
40
|
+
*/
|
|
41
|
+
const FIELD = String.raw `[\w.$+]+`;
|
|
42
|
+
/** Operator words users type, mapped to the filter's operators. */
|
|
43
|
+
const OPERATORS = {
|
|
44
|
+
'=': '=',
|
|
45
|
+
'!=': '!=',
|
|
46
|
+
'<': '<',
|
|
47
|
+
'<=': '<=',
|
|
48
|
+
'>': '>',
|
|
49
|
+
'>=': '>=',
|
|
50
|
+
contains: 'contains',
|
|
51
|
+
startswith: 'startsWith',
|
|
52
|
+
endswith: 'endsWith',
|
|
53
|
+
like: 'like',
|
|
54
|
+
in: 'in',
|
|
55
|
+
has: 'has',
|
|
56
|
+
};
|
|
57
|
+
/* Re-exported: the classifier moved to schema.ts to keep adapters from
|
|
58
|
+
importing this module, but callers here have always got it from here. */
|
|
59
|
+
export { columnKind } from './schema.js';
|
|
60
|
+
/**
|
|
61
|
+
* Split on a keyword at depth zero, honouring quotes and parentheses so that
|
|
62
|
+
* `a and (b or c)` and `name = "Smith and Sons"` both survive intact.
|
|
63
|
+
*/
|
|
64
|
+
/** Whether `word` sits at `i` as a whole word rather than inside a longer one. */
|
|
65
|
+
function isWord(text, i, word) {
|
|
66
|
+
const before = text[i - 1];
|
|
67
|
+
const after = text[i + word.length];
|
|
68
|
+
return (before === undefined || /[\s(]/.test(before)) && (after === undefined || /[\s(]/.test(after));
|
|
69
|
+
}
|
|
70
|
+
/* The same few conditions are split on every request that carries them (a view's filter, a saved query);
|
|
71
|
+
the answer is a pure function of its arguments, so a bounded recency map keeps it. The parts are only
|
|
72
|
+
read by the callers, and frozen so that stays true. */
|
|
73
|
+
const splits = new Map();
|
|
74
|
+
const MAX_SPLITS = 512;
|
|
75
|
+
function splitTopLevel(text, keyword, offset) {
|
|
76
|
+
if (text.length > 2000)
|
|
77
|
+
return splitTopLevelFresh(text, keyword, offset);
|
|
78
|
+
const key = `${keyword}\u0000${offset}\u0000${text}`;
|
|
79
|
+
const hit = splits.get(key);
|
|
80
|
+
if (hit) {
|
|
81
|
+
splits.delete(key);
|
|
82
|
+
splits.set(key, hit);
|
|
83
|
+
return hit;
|
|
84
|
+
}
|
|
85
|
+
const parts = splitTopLevelFresh(text, keyword, offset);
|
|
86
|
+
for (const part of parts)
|
|
87
|
+
Object.freeze(part);
|
|
88
|
+
Object.freeze(parts);
|
|
89
|
+
splits.set(key, parts);
|
|
90
|
+
if (splits.size > MAX_SPLITS)
|
|
91
|
+
splits.delete(splits.keys().next().value);
|
|
92
|
+
return parts;
|
|
93
|
+
}
|
|
94
|
+
function splitTopLevelFresh(text, keyword, offset) {
|
|
95
|
+
const parts = [];
|
|
96
|
+
let current = '';
|
|
97
|
+
let start = 0;
|
|
98
|
+
let quote = null;
|
|
99
|
+
let depth = 0;
|
|
100
|
+
/** Set when a `between` is open and its `and` has not been seen yet. */
|
|
101
|
+
let pendingBetween = false;
|
|
102
|
+
const lower = text.toLowerCase();
|
|
103
|
+
for (let i = 0; i < text.length; i++) {
|
|
104
|
+
const ch = text[i];
|
|
105
|
+
if (quote) {
|
|
106
|
+
/* An escaped character cannot end the quote — the same rule
|
|
107
|
+
`maskQuoted` applies below, and the reason this loop needed it:
|
|
108
|
+
without it `name = "a \\" and b"` closed its quote at the escaped
|
|
109
|
+
one, found a top-level `and` inside the value, and split a single
|
|
110
|
+
condition in half. Two quote scanners in one file disagreeing about
|
|
111
|
+
whether the grammar has escapes. */
|
|
112
|
+
if (ch === '\\' && i + 1 < text.length) {
|
|
113
|
+
current += ch + text[i + 1];
|
|
114
|
+
i += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
current += ch;
|
|
118
|
+
if (ch === quote)
|
|
119
|
+
quote = null;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (ch === '"' || ch === "'") {
|
|
123
|
+
quote = ch;
|
|
124
|
+
current += ch;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === '(')
|
|
128
|
+
depth++;
|
|
129
|
+
if (ch === ')')
|
|
130
|
+
depth = Math.max(0, depth - 1);
|
|
131
|
+
/* `between 10 and 20` contains an `and` that joins a range rather than two
|
|
132
|
+
conditions. Splitting there cut one condition in half and left `20` as
|
|
133
|
+
a condition of its own, which parsed as nothing and reported an error
|
|
134
|
+
about `20` — so the word had to be tracked rather than the grammar
|
|
135
|
+
reworded. The first top-level `and` after a `between` belongs to it. */
|
|
136
|
+
if (keyword === 'and' && lower.startsWith('between', i) && isWord(text, i, 'between')) {
|
|
137
|
+
pendingBetween = true;
|
|
138
|
+
}
|
|
139
|
+
const atBoundary = depth === 0 &&
|
|
140
|
+
lower.startsWith(keyword, i) &&
|
|
141
|
+
(i === 0 || /[\s)]/.test(text[i - 1])) &&
|
|
142
|
+
/[\s(]/.test(text[i + keyword.length] ?? ' ');
|
|
143
|
+
if (atBoundary && pendingBetween) {
|
|
144
|
+
pendingBetween = false;
|
|
145
|
+
current += text[i];
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (atBoundary) {
|
|
149
|
+
parts.push({ text: current, at: offset + start });
|
|
150
|
+
i += keyword.length - 1;
|
|
151
|
+
current = '';
|
|
152
|
+
start = i + 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
current += ch;
|
|
156
|
+
}
|
|
157
|
+
if (current.trim())
|
|
158
|
+
parts.push({ text: current, at: offset + start });
|
|
159
|
+
return parts.filter((p) => p.text.trim());
|
|
160
|
+
}
|
|
161
|
+
/** Strip one layer of wrapping parentheses, when they wrap the whole term. */
|
|
162
|
+
function unwrap(text) {
|
|
163
|
+
const t = text.trim();
|
|
164
|
+
const lead = text.length - text.trimStart().length;
|
|
165
|
+
if (!t.startsWith('(') || !t.endsWith(')'))
|
|
166
|
+
return { text: t, shift: lead };
|
|
167
|
+
let depth = 0;
|
|
168
|
+
for (let i = 0; i < t.length; i++) {
|
|
169
|
+
if (t[i] === '(')
|
|
170
|
+
depth++;
|
|
171
|
+
else if (t[i] === ')') {
|
|
172
|
+
depth--;
|
|
173
|
+
if (depth === 0 && i < t.length - 1)
|
|
174
|
+
return { text: t, shift: lead }; // `)(` — not one wrap
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { text: t.slice(1, -1).trim(), shift: lead + 1 };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether the text carries an `or` at depth zero — the one case where
|
|
181
|
+
* appending `and …` textually would regroup its meaning, since `or`
|
|
182
|
+
* binds loosest. Mirrored in the client (core.js `andQuery`), kept
|
|
183
|
+
* honest by test/mirrors.test.ts.
|
|
184
|
+
*/
|
|
185
|
+
function hasTopLevelOr(text) {
|
|
186
|
+
let depth = 0;
|
|
187
|
+
let quote = null;
|
|
188
|
+
const lower = text.toLowerCase();
|
|
189
|
+
for (let i = 0; i < text.length; i++) {
|
|
190
|
+
const ch = text[i];
|
|
191
|
+
if (quote) {
|
|
192
|
+
if (ch === '\\') {
|
|
193
|
+
i += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (ch === quote)
|
|
197
|
+
quote = null;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (ch === '"' || ch === "'") {
|
|
201
|
+
quote = ch;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (ch === '(')
|
|
205
|
+
depth++;
|
|
206
|
+
else if (ch === ')')
|
|
207
|
+
depth = Math.max(0, depth - 1);
|
|
208
|
+
else if (depth === 0 && lower.startsWith('or', i) && (i === 0 || /[\s)]/.test(text[i - 1])) && /[\s(]/.test(text[i + 2] ?? ' '))
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Join query fragments with `and`, parenthesizing any fragment whose
|
|
215
|
+
* top-level `or` would otherwise regroup: `a or b` composed with `c`
|
|
216
|
+
* must mean `(a or b) and c`, never `a or (b and c)`. Every place that
|
|
217
|
+
* folds an authored query into a narrower one goes through this.
|
|
218
|
+
*/
|
|
219
|
+
export function andQuery(...parts) {
|
|
220
|
+
const heads = [];
|
|
221
|
+
const tails = [];
|
|
222
|
+
for (const raw of parts) {
|
|
223
|
+
const p = typeof raw === 'string' ? raw.trim() : '';
|
|
224
|
+
if (!p)
|
|
225
|
+
continue;
|
|
226
|
+
/* Conditions and clauses part company here. A tab written `kind = a or
|
|
227
|
+
kind = b sort happened_on desc` is conditions *and* a sort; bracketing
|
|
228
|
+
the whole text put the sort inside the parentheses and the parser
|
|
229
|
+
could not read it. Only the conditions are composed; the clauses
|
|
230
|
+
follow, in the order their fragments came. */
|
|
231
|
+
const { head, tail } = splitClauseTail(p);
|
|
232
|
+
if (head)
|
|
233
|
+
heads.push(hasTopLevelOr(head) ? `(${head})` : head);
|
|
234
|
+
if (tail)
|
|
235
|
+
tails.push(tail);
|
|
236
|
+
}
|
|
237
|
+
return [heads.join(' and '), ...tails].filter(Boolean).join(' ');
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A fragment's conditions, and the clauses that follow them: `a = 1 sort b
|
|
241
|
+
* limit 5` is head `a = 1`, tail `sort b limit 5`. A leading `filter`/`where`
|
|
242
|
+
* is dropped from the head, since the composition supplies its own. Quoted
|
|
243
|
+
* values are masked before scanning so `name = "sort of"` stays a value; a
|
|
244
|
+
* clause word followed by an operator is a column (`limit > 5`), as the
|
|
245
|
+
* parser itself decides. Mirrored in the client (core.js), kept honest by
|
|
246
|
+
* test/mirrors.test.ts.
|
|
247
|
+
*/
|
|
248
|
+
export function splitClauseTail(text) {
|
|
249
|
+
const masked = maskQuoted(text);
|
|
250
|
+
const re = /\b(filter|where|show|select|sort|order\s+by|limit)\b/gi;
|
|
251
|
+
let m;
|
|
252
|
+
while ((m = re.exec(masked))) {
|
|
253
|
+
if (FOLLOWED_BY_OPERATOR.test(masked.slice(re.lastIndex)))
|
|
254
|
+
continue;
|
|
255
|
+
/* `any opportunity where stage = won` is one condition: its `where` opens the child rows' conditions, not a clause. */
|
|
256
|
+
if (/^(filter|where)$/i.test(m[1]) && ANY_BEFORE.test(masked.slice(0, m.index)))
|
|
257
|
+
continue;
|
|
258
|
+
if (m.index === 0 && /^(filter|where)$/i.test(m[1])) {
|
|
259
|
+
const rest = text.slice(re.lastIndex);
|
|
260
|
+
const inner = splitClauseTail(rest);
|
|
261
|
+
return { head: inner.head, tail: inner.tail };
|
|
262
|
+
}
|
|
263
|
+
return { head: text.slice(0, m.index).trim(), tail: text.slice(m.index).trim() };
|
|
264
|
+
}
|
|
265
|
+
return { head: text.trim(), tail: '' };
|
|
266
|
+
}
|
|
267
|
+
/* The start of a condition about the rows that point at a record (`any opportunity via account_id`), up to where its own `where` would stand. */
|
|
268
|
+
const ANY_BEFORE = /(?:^|[\s(])(?:any|no)\s+[A-Za-z_$][\w$.]*(?:\s+via\s+[A-Za-z_$][\w$+]*)?\s*$/i;
|
|
269
|
+
/**
|
|
270
|
+
* A condition about the rows that point at the record: `any opportunity where
|
|
271
|
+
* stage = won`, `no task via owner_id`. The child table, the reference it
|
|
272
|
+
* points through where it has several, and its own conditions — several of
|
|
273
|
+
* them in parentheses, since a top-level `and` belongs to the record's.
|
|
274
|
+
*/
|
|
275
|
+
export const ANY_CONDITION = /^(any|no)\s+([A-Za-z_$][\w$.]*)(?:\s+via\s+([A-Za-z_$][\w$+]*))?(?:\s+(?:where|filter)\s+([\s\S]+))?$/i;
|
|
276
|
+
/** Each `any …`/`no …` condition in a condition text, where it stands; a column called `any` (`any = 3`) is not one. */
|
|
277
|
+
export function anyConditions(text) {
|
|
278
|
+
const found = [];
|
|
279
|
+
const visit = (node) => {
|
|
280
|
+
if (node.kind !== 'cond')
|
|
281
|
+
return node.children.forEach(visit);
|
|
282
|
+
const word = /^(any|no)\s/i.exec(node.text);
|
|
283
|
+
if (word && ANY_CONDITION.test(node.text) && !FOLLOWED_BY_OPERATOR.test(node.text.slice(word[1].length)))
|
|
284
|
+
found.push({ at: node.at, text: node.text });
|
|
285
|
+
};
|
|
286
|
+
visit(parseExpression(text, 0));
|
|
287
|
+
return found;
|
|
288
|
+
}
|
|
289
|
+
/** `or` binds loosest, then `and`, then parentheses. */
|
|
290
|
+
function parseExpression(text, offset) {
|
|
291
|
+
const { text: inner, shift } = unwrap(text);
|
|
292
|
+
const base = offset + shift;
|
|
293
|
+
const ors = splitTopLevel(inner, 'or', base);
|
|
294
|
+
if (ors.length > 1)
|
|
295
|
+
return { kind: 'or', children: ors.map((p) => parseExpression(p.text, p.at)) };
|
|
296
|
+
const ands = splitTopLevel(inner, 'and', base);
|
|
297
|
+
if (ands.length > 1)
|
|
298
|
+
return { kind: 'and', children: ands.map((p) => parseExpression(p.text, p.at)) };
|
|
299
|
+
return { kind: 'cond', text: inner, at: base };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The conditions a query holds at its top level, segments written out and nested `and`s flattened, each in a
|
|
303
|
+
* spelling that does not depend on spacing: `a = 1 and (b = 2 and c = 3)` is three. An `or` stays one
|
|
304
|
+
* condition, its branches in order, since which branch holds is not a question of more or fewer conditions. It is
|
|
305
|
+
* how one query is shown to ask no more than another (a metric's whole, dashboards: `targetWhole`).
|
|
306
|
+
*/
|
|
307
|
+
export function topLevelConditions(text, segments) {
|
|
308
|
+
const head = splitClauseTail(expandSegments(text, segments).text).head;
|
|
309
|
+
if (!head)
|
|
310
|
+
return [];
|
|
311
|
+
const spell = (node) => node.kind === 'cond' ? node.text.replace(/\s+/g, ' ').trim() : `(${node.children.map(spell).join(` ${node.kind} `)})`;
|
|
312
|
+
const flat = (node) => (node.kind === 'and' ? node.children.flatMap(flat) : [spell(node)]);
|
|
313
|
+
return flat(parseExpression(head, 0));
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Flatten to OR-of-ANDs, which is exactly the shape `Filter` holds.
|
|
317
|
+
*
|
|
318
|
+
* The distribution can blow up on a pathological query — `(a or b) and (c or
|
|
319
|
+
* d) and (e or f)` is eight groups — so it is capped. Better to refuse than
|
|
320
|
+
* to build a statement with ten thousand clauses in it.
|
|
321
|
+
*/
|
|
322
|
+
const MAX_GROUPS = 64;
|
|
323
|
+
function toGroups(node) {
|
|
324
|
+
if (node.kind === 'cond')
|
|
325
|
+
return [[node]];
|
|
326
|
+
if (node.kind === 'or')
|
|
327
|
+
return node.children.flatMap(toGroups);
|
|
328
|
+
return node.children.reduce((groups, child) => {
|
|
329
|
+
const childGroups = toGroups(child);
|
|
330
|
+
const out = groups.flatMap((g) => childGroups.map((c) => [...g, ...c]));
|
|
331
|
+
return out.slice(0, MAX_GROUPS + 1);
|
|
332
|
+
}, [[]]);
|
|
333
|
+
}
|
|
334
|
+
/* ---------- segments: a name for conditions ----------
|
|
335
|
+
|
|
336
|
+
An App's segment (appSegments.ts) is a word the language reads where a
|
|
337
|
+
condition stands — `overdue and mine` — and writes inline, in parentheses,
|
|
338
|
+
before anything else is read. So it is the same statement as the conditions
|
|
339
|
+
written out, by construction: the paths, rollups and computed columns it
|
|
340
|
+
names are found by the view parser exactly as if they had been typed, and
|
|
341
|
+
`me` and `today` are this request's. Only a whole condition is a segment;
|
|
342
|
+
`status = open` keeps `open` a value. */
|
|
343
|
+
/** `me` read by a parse that was handed no person (ParseOptions.me). */
|
|
344
|
+
export const NO_ONE_HERE = 'me is the signed-in person, and nobody is named here to be it. Quote it ("me") to mean the text.';
|
|
345
|
+
const SEGMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
346
|
+
/** How deep segments may name segments: a chain this long is a cycle the load did not catch. */
|
|
347
|
+
const SEGMENT_DEPTH = 8;
|
|
348
|
+
/**
|
|
349
|
+
* A condition text with each segment it names written out, nested ones too.
|
|
350
|
+
* The positions of what was written stay readable through `at`, so a mistake
|
|
351
|
+
* in the author's own words is still underlined where they typed it.
|
|
352
|
+
*/
|
|
353
|
+
export function expandSegments(text, segments, chain = []) {
|
|
354
|
+
const same = { text, at: (position) => position };
|
|
355
|
+
if (!segments || !text.trim())
|
|
356
|
+
return same;
|
|
357
|
+
const words = [];
|
|
358
|
+
const visit = (node) => {
|
|
359
|
+
if (node.kind === 'cond') {
|
|
360
|
+
if (SEGMENT_WORD.test(node.text) && Object.hasOwn(segments, node.text))
|
|
361
|
+
words.push({ at: node.at, name: node.text });
|
|
362
|
+
}
|
|
363
|
+
else
|
|
364
|
+
node.children.forEach(visit);
|
|
365
|
+
};
|
|
366
|
+
visit(parseExpression(text, 0));
|
|
367
|
+
if (!words.length)
|
|
368
|
+
return same;
|
|
369
|
+
const splices = [];
|
|
370
|
+
let out = '';
|
|
371
|
+
let last = 0;
|
|
372
|
+
for (const word of words.sort((a, b) => a.at - b.at)) {
|
|
373
|
+
if (chain.includes(word.name) || chain.length >= SEGMENT_DEPTH) {
|
|
374
|
+
return {
|
|
375
|
+
...same,
|
|
376
|
+
error: {
|
|
377
|
+
message: `The segment "${word.name}" names itself through ${[...chain, word.name].map((name) => `"${name}"`).join(' → ')}.`,
|
|
378
|
+
at: word.at,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const inner = expandSegments(segments[word.name], segments, [...chain, word.name]);
|
|
383
|
+
if (inner.error)
|
|
384
|
+
return { ...same, error: { message: inner.error.message, at: word.at } };
|
|
385
|
+
out += text.slice(last, word.at);
|
|
386
|
+
const written = `(${inner.text})`;
|
|
387
|
+
splices.push({ at: word.at, was: word.name.length, from: out.length, length: written.length });
|
|
388
|
+
out += written;
|
|
389
|
+
last = word.at + word.name.length;
|
|
390
|
+
}
|
|
391
|
+
out += text.slice(last);
|
|
392
|
+
return {
|
|
393
|
+
text: out,
|
|
394
|
+
at: (position) => {
|
|
395
|
+
let shift = 0;
|
|
396
|
+
for (const splice of splices) {
|
|
397
|
+
if (position < splice.from)
|
|
398
|
+
break;
|
|
399
|
+
if (position < splice.from + splice.length)
|
|
400
|
+
return splice.at;
|
|
401
|
+
shift = splice.from + splice.length - (splice.at + splice.was);
|
|
402
|
+
}
|
|
403
|
+
return position - shift;
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/* ---------- value coercion ----------
|
|
408
|
+
|
|
409
|
+
The schema is the reason this is possible at all. Knowing the column's
|
|
410
|
+
declared type lets `active = true` mean the boolean `true` on Postgres and
|
|
411
|
+
the integer `1` on SQLite, without the user thinking about either. */
|
|
412
|
+
/**
|
|
413
|
+
* Take the quotes off a value, and the escapes out of it.
|
|
414
|
+
*
|
|
415
|
+
* The unescaping half was missing, and its absence was invisible because the
|
|
416
|
+
* *tokenizer* has always understood `\"` — `maskQuoted` skips an
|
|
417
|
+
* escaped character precisely so it cannot end the quote. So `name =
|
|
418
|
+
* "Harbour \"HQ\""` was split into a clause correctly and then bound as the
|
|
419
|
+
* literal `Harbour \"HQ\"`, backslashes and all, which matches no row. Two
|
|
420
|
+
* halves of one grammar disagreeing about whether it has escapes.
|
|
421
|
+
*
|
|
422
|
+
* Only `\"`, `\'` and `\\` are unescaped. This is not C: `\n` in a value
|
|
423
|
+
* is a backslash and an n, because that is what someone typing a Windows path
|
|
424
|
+
* into a filter means by it.
|
|
425
|
+
*/
|
|
426
|
+
function unquoteValue(raw) {
|
|
427
|
+
const t = raw.trim();
|
|
428
|
+
if ((t.startsWith('"') && t.endsWith('"') && t.length > 1) || (t.startsWith("'") && t.endsWith("'") && t.length > 1)) {
|
|
429
|
+
return { value: unescapeValue(t.slice(1, -1)), quoted: true };
|
|
430
|
+
}
|
|
431
|
+
return { value: t, quoted: false };
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Write a value back into the grammar, quoting only when it needs it.
|
|
435
|
+
*
|
|
436
|
+
* The exact counterpart of `unquoteValue` above, and the reason it lives here
|
|
437
|
+
* rather than beside the caller: quoting used to be written out wherever a
|
|
438
|
+
* value had to be turned back into query text — three times in the client and
|
|
439
|
+
* once here — and the four disagreed about an embedded `"`, so the same value
|
|
440
|
+
* produced a different filter depending on which button was pressed.
|
|
441
|
+
*
|
|
442
|
+
* The backslash is escaped before the quote, or a value ending in one would
|
|
443
|
+
* escape its own closing quote and swallow the rest of the query. The client
|
|
444
|
+
* has its own copy in `core.quote`, because the browser cannot import this
|
|
445
|
+
* file; `mirrors.test.ts` asserts the two agree character for character.
|
|
446
|
+
*/
|
|
447
|
+
export function quoteValue(value) {
|
|
448
|
+
const text = String(value);
|
|
449
|
+
/* A dot is left bare only in a number: `a.b` is a path wherever `a` is a
|
|
450
|
+
column, which the probe below cannot know about a real table. */
|
|
451
|
+
if (/^(?:[\w-]+|-?\d+\.\d+)$/.test(text) && readsAsItself(text))
|
|
452
|
+
return text;
|
|
453
|
+
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
454
|
+
}
|
|
455
|
+
/* The probe `readsAsItself` parses against: one text column, so a bare value
|
|
456
|
+
is bound as the text it is and nothing about a real table can change the
|
|
457
|
+
answer, and a session, so `me` is read as the person it would name. */
|
|
458
|
+
const PROBE_SCHEMA = {
|
|
459
|
+
label: 'probe',
|
|
460
|
+
dialect: 'sqlite',
|
|
461
|
+
foreignKeys: [],
|
|
462
|
+
readAt: '',
|
|
463
|
+
tables: [
|
|
464
|
+
{
|
|
465
|
+
name: 'probe',
|
|
466
|
+
schema: '',
|
|
467
|
+
id: 'probe',
|
|
468
|
+
isView: false,
|
|
469
|
+
columns: [{ name: 'value', type: 'text', nullable: true, primaryKey: false }],
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
const PROBE_ME = { email: 'probe@example.invalid' };
|
|
474
|
+
const PROBE_NOW = new Date(0);
|
|
475
|
+
/**
|
|
476
|
+
* Whether a bare value would be read back as exactly itself, both after `=`
|
|
477
|
+
* and inside `in (…)`.
|
|
478
|
+
*
|
|
479
|
+
* Asked of the parser rather than of a list of its words, because a list is a
|
|
480
|
+
* second copy of the grammar and the copy was the bug: the old rule was "any
|
|
481
|
+
* run of word characters, dots and hyphens is safe", and a card photo's slug
|
|
482
|
+
* `2023-mazda-cx-5-select-lf1015` carried `select`, which the parser reads as
|
|
483
|
+
* a clause. The request was refused and every card on the page lost its
|
|
484
|
+
* photo. A word the grammar learns later (`me`, a date phrase, a clause) is
|
|
485
|
+
* quoted here the day it is learned, with nothing to keep in step.
|
|
486
|
+
*/
|
|
487
|
+
function readsAsItself(text) {
|
|
488
|
+
const read = (query) => parseQuery(query, { schema: PROBE_SCHEMA, me: PROBE_ME, now: PROBE_NOW });
|
|
489
|
+
const equal = read(`probe value = ${text}`);
|
|
490
|
+
const listed = read(`probe value in (${text})`);
|
|
491
|
+
const only = (result) => result.errors.length === 0 && result.query?.filter.groups.length === 1 && result.query.filter.groups[0].length === 1
|
|
492
|
+
? result.query.filter.groups[0][0]
|
|
493
|
+
: undefined;
|
|
494
|
+
const one = only(equal);
|
|
495
|
+
const inList = only(listed);
|
|
496
|
+
return (one?.op === '=' &&
|
|
497
|
+
one.value === text &&
|
|
498
|
+
inList?.op === 'in' &&
|
|
499
|
+
Array.isArray(inList.value) &&
|
|
500
|
+
inList.value.length === 1 &&
|
|
501
|
+
inList.value[0] === text);
|
|
502
|
+
}
|
|
503
|
+
function unescapeValue(text) {
|
|
504
|
+
return text.replace(/\\(["'\\])/g, '$1');
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Dates are bound as ISO strings because both target dialects compare them
|
|
508
|
+
* correctly that way: Postgres casts on the way in, and SQLite's documented
|
|
509
|
+
* text-date format is exactly ISO-8601. A `Date` object handed to a driver
|
|
510
|
+
* is at the mercy of whatever it decides to do with the local timezone.
|
|
511
|
+
*/
|
|
512
|
+
function bindDate(d) {
|
|
513
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
514
|
+
const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
515
|
+
/* A midnight boundary is bound as the bare day. Text comparison sorts
|
|
516
|
+
'2026-08-30' before '2026-08-30 00:00:00', so appending the zero time
|
|
517
|
+
made every bare-DATE column compare wrong at the boundary — SQLite
|
|
518
|
+
counted a task due *today* as `< today`, i.e. overdue. Bare, the bound
|
|
519
|
+
is right for every storage shape at once: it sorts before any datetime
|
|
520
|
+
of its day, and a driver casting for a real date/timestamp column reads
|
|
521
|
+
it as midnight exactly as before. */
|
|
522
|
+
if (!d.getHours() && !d.getMinutes() && !d.getSeconds())
|
|
523
|
+
return day;
|
|
524
|
+
return `${day} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
525
|
+
}
|
|
526
|
+
function coerce(column, value, quoted, op) {
|
|
527
|
+
const kind = column ? columnKind(column.type) : 'other';
|
|
528
|
+
if (quoted)
|
|
529
|
+
return value; // an explicit quote is an explicit "this is text"
|
|
530
|
+
/* A key of a JSON column (`specs.capacity`) is compared as its text, which
|
|
531
|
+
is how an equality reads the same on every engine and for every value a
|
|
532
|
+
key holds (`true` for a yes); only an ordering against a number compares
|
|
533
|
+
numbers (viewLabels.ts picks the expression). */
|
|
534
|
+
if (column?.type === DEFINED_PATH_TYPE) {
|
|
535
|
+
const n = Number(value);
|
|
536
|
+
return op && RANGE_OPS.has(op) && value !== '' && Number.isFinite(n) ? n : value;
|
|
537
|
+
}
|
|
538
|
+
const truthy = /^(true|yes)$/i.test(value);
|
|
539
|
+
const falsy = /^(false|no)$/i.test(value);
|
|
540
|
+
if (kind === 'boolean') {
|
|
541
|
+
if (truthy || value === '1')
|
|
542
|
+
return true;
|
|
543
|
+
if (falsy || value === '0')
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
/* An integer past 2^53 is not a JavaScript number. `id = 9007199254740993`
|
|
547
|
+
through Number() binds …992 and finds the wrong row, or none, with
|
|
548
|
+
nothing about it that looks wrong — the same rounding the adapters
|
|
549
|
+
guard against on the way *out* (ids past 2^53 come back as strings).
|
|
550
|
+
Left as text, every driver hands the digits to the database whole and
|
|
551
|
+
the column's own type does the comparison. */
|
|
552
|
+
if (/^-?\d+$/.test(value) && !Number.isSafeInteger(Number(value)))
|
|
553
|
+
return value;
|
|
554
|
+
if (kind === 'number') {
|
|
555
|
+
/* SQLite has no boolean type, so a flag column is declared INTEGER and
|
|
556
|
+
stores 1 and 0. Someone typing `active = true` against it means the
|
|
557
|
+
integer, and binding the string 'true' would match nothing while
|
|
558
|
+
looking exactly like an empty table. */
|
|
559
|
+
if (truthy)
|
|
560
|
+
return 1;
|
|
561
|
+
if (falsy)
|
|
562
|
+
return 0;
|
|
563
|
+
const n = Number(value);
|
|
564
|
+
if (Number.isFinite(n) && value !== '')
|
|
565
|
+
return n;
|
|
566
|
+
}
|
|
567
|
+
if (kind === 'other') {
|
|
568
|
+
/* An untyped column compared to a bare number is still probably a number
|
|
569
|
+
— SQLite's dynamic typing makes untyped columns common. */
|
|
570
|
+
if (/^-?\d+(\.\d+)?$/.test(value))
|
|
571
|
+
return Number(value);
|
|
572
|
+
if (truthy)
|
|
573
|
+
return true;
|
|
574
|
+
if (falsy)
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
/* ---------- condition compilation ---------- */
|
|
580
|
+
function resolveColumn(table, name) {
|
|
581
|
+
if (!table)
|
|
582
|
+
return {};
|
|
583
|
+
const exact = table.columns.find((c) => c.name === name);
|
|
584
|
+
if (exact)
|
|
585
|
+
return { column: exact };
|
|
586
|
+
// Case-insensitive next: people type `Name` for `name` constantly, and
|
|
587
|
+
// both dialects fold unquoted identifiers anyway.
|
|
588
|
+
const insensitive = table.columns.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
589
|
+
if (insensitive)
|
|
590
|
+
return { column: insensitive };
|
|
591
|
+
/* A reference path read where only the table's own columns can be: the
|
|
592
|
+
plain query has no joins, and "not a column" is true and useless when
|
|
593
|
+
the first hop plainly is one. The view parser admits paths before this
|
|
594
|
+
is reached, so this is said only where a path genuinely cannot go. */
|
|
595
|
+
const hop = name.split('.')[0];
|
|
596
|
+
if (name.includes('.') && table.columns.some((c) => c.name === hop)) {
|
|
597
|
+
return { error: `"${name}" is a path, and a condition here reads only ${table.name}'s own columns.` };
|
|
598
|
+
}
|
|
599
|
+
const near = nearest(name, table.columns.map((c) => c.name));
|
|
600
|
+
const suggestion = near ? ` Did you mean "${near}"?` : '';
|
|
601
|
+
return { error: `"${name}" is not a column on ${table.name}.${suggestion}` };
|
|
602
|
+
}
|
|
603
|
+
export { ANYONE } from './queryOperands.js';
|
|
604
|
+
/* A record's tags are asked only whether they hold a label: `tags = bug` would
|
|
605
|
+
compare the list itself, which is no question anyone means. */
|
|
606
|
+
function compileCondition(raw, ctx) {
|
|
607
|
+
const compiled = compileClause(raw, ctx);
|
|
608
|
+
const tagged = compiled.conditions?.find((c) => c.op !== 'has' && ctx.table?.columns.find((col) => col.name === c.column)?.type === TAGS_TYPE);
|
|
609
|
+
if (tagged)
|
|
610
|
+
return { error: `"${tagged.column}" is a record's tags: ask it with has, as in ${tagged.column} has bug.` };
|
|
611
|
+
return compiled;
|
|
612
|
+
}
|
|
613
|
+
function compileClause(raw, ctx) {
|
|
614
|
+
const { table, now } = ctx;
|
|
615
|
+
const text = raw.trim();
|
|
616
|
+
/**
|
|
617
|
+
* `column between a and b`.
|
|
618
|
+
*
|
|
619
|
+
* Expanded into `>= a` and `<= b` rather than carried as an operator of its
|
|
620
|
+
* own. The filter model would need a new `Op` in three adapters to express
|
|
621
|
+
* a range it can already express, and every one of them would have to get
|
|
622
|
+
* the inclusive ends right. Two conditions is the same set of rows, needs
|
|
623
|
+
* nothing new, and shows in the SQL panel as exactly what it does.
|
|
624
|
+
*
|
|
625
|
+
* Inclusive at both ends, which is what SQL's BETWEEN means and what
|
|
626
|
+
* someone typing `total between 100 and 500` expects of `500`.
|
|
627
|
+
*/
|
|
628
|
+
const between = text.match(new RegExp(String.raw `^(${FIELD})\s+(not\s+)?between\s+(.+?)\s+and\s+(.+)$`, 'i'));
|
|
629
|
+
if (between) {
|
|
630
|
+
const [, field, not, lowRaw, highRaw] = between;
|
|
631
|
+
const { column, error } = resolveColumn(table, field);
|
|
632
|
+
if (error)
|
|
633
|
+
return { error };
|
|
634
|
+
const name = column?.name ?? field;
|
|
635
|
+
if (not) {
|
|
636
|
+
return {
|
|
637
|
+
error: `"not between" is not supported. Write it as two conditions: ` + `${name} < ${lowRaw.trim()} or ${name} > ${highRaw.trim()}`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const low = unquoteValue(lowRaw);
|
|
641
|
+
const high = unquoteValue(highRaw);
|
|
642
|
+
if (!low.value || !high.value) {
|
|
643
|
+
return { error: `"between" needs two values, as in ${name} between 10 and 20` };
|
|
644
|
+
}
|
|
645
|
+
/* Each end coerced on its own, so a date range gets the same treatment a
|
|
646
|
+
single date comparison does — `between 2024-01-01 and 2024-06-30` binds
|
|
647
|
+
two timestamps, not two strings. */
|
|
648
|
+
return {
|
|
649
|
+
conditions: [
|
|
650
|
+
{ column: name, op: '>=', value: coerce(column, low.value, low.quoted, '>=') },
|
|
651
|
+
{ column: name, op: '<=', value: coerce(column, high.value, high.quoted, '<=') },
|
|
652
|
+
],
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/* `status is open`: the machine's states that are not final; `is not open`, the final ones (appMachine.ts). A
|
|
656
|
+
group the machine names reads the same way: `status is onBench` is its states, `is not onBench` the others. */
|
|
657
|
+
const open = text.match(new RegExp(String.raw `^(${FIELD})\s+is\s+(not\s+)?([A-Za-z]\w*)$`, 'i'));
|
|
658
|
+
if (open && open[3].toLowerCase() !== 'empty') {
|
|
659
|
+
const [, field, not, word] = open;
|
|
660
|
+
const { column, error } = resolveColumn(table, field);
|
|
661
|
+
if (error)
|
|
662
|
+
return { error };
|
|
663
|
+
const name = column?.name ?? field;
|
|
664
|
+
const states = column?.states ?? ctx.states?.[name];
|
|
665
|
+
if (!states)
|
|
666
|
+
return {
|
|
667
|
+
error: `"${name} is ${not ? 'not ' : ''}${word}" asks a machine's column, and "${name}" has no machine here. Name its states: ${name} in (…).`,
|
|
668
|
+
};
|
|
669
|
+
if (word.toLowerCase() !== 'open') {
|
|
670
|
+
const group = states.groups && Object.hasOwn(states.groups, word) ? states.groups[word] : undefined;
|
|
671
|
+
if (!group) {
|
|
672
|
+
const groups = Object.keys(states.groups ?? {});
|
|
673
|
+
return {
|
|
674
|
+
error: `"${name}" has no group "${word}": \`is\` reads open or one of its machine's groups` +
|
|
675
|
+
`${groups.length ? ` (${groups.join(', ')})` : ', and it names none'}. A single state is ${name} = ${word}.`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
return not
|
|
679
|
+
? { conditions: group.map((state) => ({ column: name, op: '!=', value: state })) }
|
|
680
|
+
: { conditions: [{ column: name, op: 'in', value: [...group] }] };
|
|
681
|
+
}
|
|
682
|
+
const values = not ? states.final : states.open;
|
|
683
|
+
if (!values.length)
|
|
684
|
+
return {
|
|
685
|
+
conditions: [
|
|
686
|
+
{ column: name, op: 'isNull' },
|
|
687
|
+
{ column: name, op: 'isNotNull' },
|
|
688
|
+
],
|
|
689
|
+
};
|
|
690
|
+
return { conditions: [{ column: name, op: 'in', value: [...values] }] };
|
|
691
|
+
}
|
|
692
|
+
const empty = text.match(new RegExp(String.raw `^([\w.$+"]+)\s+is\s+(not\s+)?empty$`, 'i'));
|
|
693
|
+
if (empty) {
|
|
694
|
+
const [, field, not] = empty;
|
|
695
|
+
const { column, error } = resolveColumn(table, field);
|
|
696
|
+
if (error)
|
|
697
|
+
return { error };
|
|
698
|
+
return { conditions: [{ column: column?.name ?? field, op: not ? 'isNotNull' : 'isNull' }] };
|
|
699
|
+
}
|
|
700
|
+
// Word operators are tried first because they are multi-character and a
|
|
701
|
+
// symbol match would otherwise never see them.
|
|
702
|
+
const word = text.match(new RegExp(String.raw `^(${FIELD})\s+(not\s+contains|contains|startswith|endswith|like|not\s+in|in|has)\s+(.+)$`, 'i'));
|
|
703
|
+
if (word) {
|
|
704
|
+
const [, field, opRaw, valueRaw] = word;
|
|
705
|
+
const { column, error } = resolveColumn(table, field);
|
|
706
|
+
if (error)
|
|
707
|
+
return { error };
|
|
708
|
+
const name = column?.name ?? field;
|
|
709
|
+
const key = opRaw.toLowerCase().replace(/\s+/g, ' ');
|
|
710
|
+
const negated = key.startsWith('not ');
|
|
711
|
+
const op = OPERATORS[negated ? key.slice(4) : key];
|
|
712
|
+
if (!op)
|
|
713
|
+
return { error: `Unknown operator "${opRaw}"` };
|
|
714
|
+
/* `tags has bug`, `tags has (bug, docs)`: a record's tags (TableMeta.tags)
|
|
715
|
+
hold any of the labels. A set, so a list never needs `in`. */
|
|
716
|
+
if (op === 'has') {
|
|
717
|
+
if (column?.type !== TAGS_TYPE && column?.type !== DEFINED_PATH_TYPE)
|
|
718
|
+
return { error: `"has" asks a record's tags or a key of a JSON column, and "${name}" is neither.` };
|
|
719
|
+
const list = valueRaw
|
|
720
|
+
.trim()
|
|
721
|
+
.replace(/^[([]\s*/, '')
|
|
722
|
+
.replace(/\s*[)\]]$/, '');
|
|
723
|
+
const labels = list
|
|
724
|
+
.split(',')
|
|
725
|
+
.map((s) => unquoteValue(s).value)
|
|
726
|
+
.filter((s) => s !== '');
|
|
727
|
+
if (!labels.length)
|
|
728
|
+
return { error: `"has" needs a tag, as in ${name} has bug` };
|
|
729
|
+
return { conditions: [{ column: name, op: 'has', value: labels }] };
|
|
730
|
+
}
|
|
731
|
+
if (op === 'in') {
|
|
732
|
+
// `in (a, b)` and `in [a, b]` read more naturally than a bare list.
|
|
733
|
+
const list = valueRaw
|
|
734
|
+
.trim()
|
|
735
|
+
.replace(/^[([]\s*/, '')
|
|
736
|
+
.replace(/\s*[)\]]$/, '');
|
|
737
|
+
const items = list
|
|
738
|
+
.split(',')
|
|
739
|
+
.map((s) => unquoteValue(s))
|
|
740
|
+
.filter((s) => s.value !== '');
|
|
741
|
+
if (!items.length)
|
|
742
|
+
return { error: `"${opRaw}" needs at least one value` };
|
|
743
|
+
const values = [];
|
|
744
|
+
for (const item of items) {
|
|
745
|
+
/* The signed-in person's memberships (`assignment_group_id in (me.group_member.group_id)`): the values read for this request through its own reader. Where nobody is meant, it is no text. */
|
|
746
|
+
if (!item.quoted && ME_SET.test(item.value)) {
|
|
747
|
+
if (ctx.me === undefined)
|
|
748
|
+
return {
|
|
749
|
+
error: `${item.value} is the signed-in person's rows, and nobody is signed in here. Quote it ("${item.value}") to mean the text.`,
|
|
750
|
+
};
|
|
751
|
+
const set = meSetOperand(ctx, column, item.value);
|
|
752
|
+
if (set.error)
|
|
753
|
+
return { error: set.error };
|
|
754
|
+
values.push(...set.values);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
/* A group the machine names is asked with `is` (`status is onBench`), as `is open` is: one way to say it. */
|
|
758
|
+
const groups = item.quoted ? undefined : (column?.states ?? ctx.states?.[name])?.groups;
|
|
759
|
+
if (groups && Object.hasOwn(groups, item.value))
|
|
760
|
+
return { error: `"${item.value}" is a group of ${name}'s states; ask it with is: ${name} is ${item.value}.` };
|
|
761
|
+
if (!item.quoted && item.value === 'me') {
|
|
762
|
+
if (ctx.me === undefined)
|
|
763
|
+
return { error: NO_ONE_HERE };
|
|
764
|
+
const me = meOperand(ctx, column, '=');
|
|
765
|
+
if (me.error)
|
|
766
|
+
return { error: me.error };
|
|
767
|
+
if (me.value !== undefined)
|
|
768
|
+
values.push(me.value);
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
values.push(coerce(column, item.value, item.quoted));
|
|
772
|
+
}
|
|
773
|
+
if (negated) {
|
|
774
|
+
/* There is no NOT IN in the filter model, and adding one would mean a
|
|
775
|
+
new operator in every adapter. An expanded chain of != is the same
|
|
776
|
+
set and needs nothing new. */
|
|
777
|
+
return { conditions: values.map((v) => ({ column: name, op: '!=', value: v })) };
|
|
778
|
+
}
|
|
779
|
+
return { conditions: [{ column: name, op: 'in', value: values }] };
|
|
780
|
+
}
|
|
781
|
+
if (negated) {
|
|
782
|
+
return { error: `"not ${op}" is not supported yet — use "is empty" or a different operator` };
|
|
783
|
+
}
|
|
784
|
+
const { value, quoted } = unquoteValue(valueRaw);
|
|
785
|
+
// LIKE operands stay text even on a numeric column; `contains 5` on an
|
|
786
|
+
// integer is a substring question, not an equality one.
|
|
787
|
+
return { conditions: [{ column: name, op, value: quoted ? value : value }] };
|
|
788
|
+
}
|
|
789
|
+
const symbol = text.match(new RegExp(String.raw `^(${FIELD})\s*(!=|<=|>=|=|<|>)\s*(.+)$`));
|
|
790
|
+
if (symbol) {
|
|
791
|
+
const [, field, opRaw, valueRaw] = symbol;
|
|
792
|
+
const { column, error } = resolveColumn(table, field);
|
|
793
|
+
if (error)
|
|
794
|
+
return { error };
|
|
795
|
+
const name = column?.name ?? field;
|
|
796
|
+
const op = OPERATORS[opRaw];
|
|
797
|
+
const { value, quoted } = unquoteValue(valueRaw);
|
|
798
|
+
/* SQL's NULL is not a word this language compares with: `= null` bound
|
|
799
|
+
the four letters and matched nothing, which reads as an empty list
|
|
800
|
+
rather than a mistake — and in an App, as a count that is always 0. */
|
|
801
|
+
if (!quoted && /^null$/i.test(value)) {
|
|
802
|
+
return {
|
|
803
|
+
error: `"${field} ${opRaw} ${value}" compares with the word "${value}": write "${field} is ${op === '!=' ? 'not ' : ''}empty" for a missing value, or quote it ("${value}") to mean the text.`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
/* `me` is resolved here, in the parse the server runs for this request,
|
|
807
|
+
so the person a tab means is the session's and never the browser's. */
|
|
808
|
+
if (!quoted && ME_SET.test(value) && ctx.me !== undefined)
|
|
809
|
+
return { error: `${value} is a set of values: ask it with in, as in ${field} in (${value}).` };
|
|
810
|
+
/* A parse that was handed no person cannot say whose `me` it is. Read as the two letters, `owner != me` was
|
|
811
|
+
every row, silently, on any path that forgot the person: refused here, so it forgets loudly. A path where
|
|
812
|
+
nobody is signed in says so (`me: null`); a check before any request reads it as anyone's (ANYONE). */
|
|
813
|
+
if (!quoted && value === 'me' && ctx.me === undefined)
|
|
814
|
+
return { error: NO_ONE_HERE };
|
|
815
|
+
if (!quoted && value === 'me') {
|
|
816
|
+
const me = meOperand(ctx, column, op);
|
|
817
|
+
if (me.error)
|
|
818
|
+
return { error: me.error };
|
|
819
|
+
if (me.conditions)
|
|
820
|
+
return { conditions: me.conditions };
|
|
821
|
+
return { conditions: [{ column: name, op, value: me.value }] };
|
|
822
|
+
}
|
|
823
|
+
if (!quoted) {
|
|
824
|
+
const operand = columnOperand(ctx, column, op, value);
|
|
825
|
+
if (operand.error)
|
|
826
|
+
return { error: operand.error };
|
|
827
|
+
if (operand.other)
|
|
828
|
+
return { conditions: [{ column: name, op, other: operand.other.name }] };
|
|
829
|
+
}
|
|
830
|
+
/* `now` beside a date is the instant, bound as a date phrase's edges are, so `resolution_due_at < now` reads
|
|
831
|
+
the same on every engine. Bound as the word, PostgreSQL alone took it for the clock (SQLite compared the
|
|
832
|
+
text, and every row was before it), and the typed query refused what the segment had passed. */
|
|
833
|
+
if (!quoted && value.toLowerCase() === 'now' && column && columnKind(column.type) === 'date') {
|
|
834
|
+
nowRead = 2;
|
|
835
|
+
return { conditions: [{ column: name, op, value: bindDate(now) }] };
|
|
836
|
+
}
|
|
837
|
+
/* A date phrase is a range, not a point, so `=` becomes two conditions.
|
|
838
|
+
Getting this wrong is the failure this language is built to avoid: an
|
|
839
|
+
unparseable phrase sent through as a literal matches nothing, and a
|
|
840
|
+
filter that silently matches nothing looks exactly like a table that is
|
|
841
|
+
genuinely empty. */
|
|
842
|
+
if (!quoted) {
|
|
843
|
+
const window = dateWindow(value, now);
|
|
844
|
+
if (window) {
|
|
845
|
+
nowRead = /\b(?:hour|minute)s?$/.test(window.label) ? 2 : Math.max(nowRead, 1);
|
|
846
|
+
if (op === '=') {
|
|
847
|
+
return {
|
|
848
|
+
conditions: [
|
|
849
|
+
{ column: name, op: '>=', value: bindDate(window.start) },
|
|
850
|
+
{ column: name, op: '<', value: bindDate(window.end) },
|
|
851
|
+
],
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
if (op === '>=' || op === '<')
|
|
855
|
+
return { conditions: [{ column: name, op, value: bindDate(window.start) }] };
|
|
856
|
+
/* The inclusive side of a range is half-open against the *next* day:
|
|
857
|
+
`<= today` must take everything before tomorrow (a datetime late
|
|
858
|
+
tonight included), and `> today` everything from tomorrow on.
|
|
859
|
+
Bound with their literal operators they read the boundary itself
|
|
860
|
+
as a row, which admitted tomorrow into "<= today". */
|
|
861
|
+
if (op === '>')
|
|
862
|
+
return { conditions: [{ column: name, op: '>=', value: bindDate(window.end) }] };
|
|
863
|
+
if (op === '<=')
|
|
864
|
+
return { conditions: [{ column: name, op: '<', value: bindDate(window.end) }] };
|
|
865
|
+
return { error: `"${value}" is a date range — use =, >=, >, <= or < with it, not ${opRaw}` };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/* A bare multi-word value is nearly always a missing pair of quotes. It
|
|
869
|
+
would otherwise bind as a literal that matches nothing, which reads as
|
|
870
|
+
"no results" rather than "you made a mistake". The message shows the
|
|
871
|
+
whole condition quoted, because an author (or an agent) copies the fix
|
|
872
|
+
rather than reading it; a date phrase is offered only where the column
|
|
873
|
+
holds dates, since on a status column it sent people looking for one. */
|
|
874
|
+
/* `total >> 5` leaves the value `> 5`. Telling an author to quote that, as the message below would, sends
|
|
875
|
+
them to search for a literal "> 5" and match nothing; the mistake is a second operator, so say so. */
|
|
876
|
+
if (!quoted && /^[<>=!]/.test(value)) {
|
|
877
|
+
return { error: `${opRaw}${value.match(/^[<>=!]+/)[0]} is not an operator; use one of =, !=, >, >=, < or <= before the value.` };
|
|
878
|
+
}
|
|
879
|
+
if (!quoted && /\s/.test(value)) {
|
|
880
|
+
const dated = column && columnKind(column.type) === 'date';
|
|
881
|
+
return {
|
|
882
|
+
error: `The value ${value} has a space, so quote it: ${field} ${opRaw} "${value}"${dated ? ', or use a date phrase like today or last 30 days' : ''}.`,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
return { conditions: [{ column: name, op, value: coerce(column, value, quoted, op) }] };
|
|
886
|
+
}
|
|
887
|
+
/* A bare column name is a common half-typed state, and it is ambiguous in a
|
|
888
|
+
useful way: the user may be starting a filter, or may want projection and
|
|
889
|
+
not know the word for it. Naming both is how `show` gets discovered —
|
|
890
|
+
an error that only suggests filtering teaches that projection does not
|
|
891
|
+
exist. */
|
|
892
|
+
if (new RegExp(String.raw `^${FIELD}$`).test(text) && table) {
|
|
893
|
+
const { column } = resolveColumn(table, text);
|
|
894
|
+
if (column) {
|
|
895
|
+
return {
|
|
896
|
+
error: `What about "${text}"? To filter, try ${text} = … or ${text} is not empty. ` +
|
|
897
|
+
`To show only that column, put "show" first: show ${text}`,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
/* A word alone where a condition stands is a segment's name (expandSegments wrote out every one it knew). */
|
|
901
|
+
const names = Object.keys(table.segments ?? {});
|
|
902
|
+
if (names.length && SEGMENT_WORD.test(text)) {
|
|
903
|
+
const near = nearest(text, names);
|
|
904
|
+
return {
|
|
905
|
+
error: `"${text}" is not a segment of ${table.name}. Its segments: ${names.join(', ')}.${near ? ` Did you mean "${near}"?` : ''}`,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return { error: `Could not read "${text}" — try e.g. status = open, or amount > 100` };
|
|
910
|
+
}
|
|
911
|
+
/* ---------- the parser ---------- */
|
|
912
|
+
/**
|
|
913
|
+
* What makes a clause word a column name instead: an operator right after it.
|
|
914
|
+
* A table with a column called `filter`, `sort` or `limit` is not unusual, and
|
|
915
|
+
* eating the word as a clause turns `limit is empty` into a nonsense query.
|
|
916
|
+
*/
|
|
917
|
+
const FOLLOWED_BY_OPERATOR = /^\s*(?:=|!=|>=|<=|>|<|\bis\s+not\s+empty\b|\bis\s+empty\b|\bis\b|\bcontains\b|\bstartswith\b|\bendswith\b|\blike\b|\bnot\s+in\b|\bin\b|\bhas\b)/i;
|
|
918
|
+
const CLAUSE_RE = /\b(filter|where|show|select|sort|order\s+by|limit)\b/gi;
|
|
919
|
+
/** The hyphenated word a clause keyword at [start, end) is part of (`no-show`), or undefined where it stands alone. */
|
|
920
|
+
function hyphenatedValue(text, start, end) {
|
|
921
|
+
if (text[start - 1] !== '-' && text[end] !== '-')
|
|
922
|
+
return undefined;
|
|
923
|
+
let from = start;
|
|
924
|
+
let to = end;
|
|
925
|
+
while (from > 0 && /[\w-]/.test(text[from - 1]))
|
|
926
|
+
from -= 1;
|
|
927
|
+
while (to < text.length && /[\w-]/.test(text[to]))
|
|
928
|
+
to += 1;
|
|
929
|
+
return { start: from, end: to };
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* What to say about a clause given twice.
|
|
933
|
+
*
|
|
934
|
+
* `filter` is the one that combines, and it is not routed here: two conditions
|
|
935
|
+
* genuinely narrow one result, so they are joined with `and`. Nothing else
|
|
936
|
+
* does. `show a show b` used to compile as a single column literally named
|
|
937
|
+
* "a and b" and then fail as an unknown column — an error naming a word the
|
|
938
|
+
* user never typed, about a mistake it never mentioned.
|
|
939
|
+
*
|
|
940
|
+
* Refusing beats picking one. There is no reading of two `limit`s under which
|
|
941
|
+
* the discarded number was not asked for, and a silently dropped clause is the
|
|
942
|
+
* failure this language exists to avoid: the answer looks like an answer. So
|
|
943
|
+
* the clause is named, and — where the language has a way to say both things
|
|
944
|
+
* at once — the comma that does it is shown.
|
|
945
|
+
*/
|
|
946
|
+
function repeatedClause(keyword) {
|
|
947
|
+
const combine = {
|
|
948
|
+
show: 'Name every column in one, separated by commas: show first_name, last_name',
|
|
949
|
+
sort: 'Name every field in one, separated by commas: sort invoice_date desc, id',
|
|
950
|
+
limit: 'Only one number can be the number of rows — keep the one you meant.',
|
|
951
|
+
};
|
|
952
|
+
return `Two "${keyword}" clauses, and the second cannot add to the first. ${combine[keyword] ?? 'Say it once.'}`;
|
|
953
|
+
}
|
|
954
|
+
function normalizeClause(word) {
|
|
955
|
+
const w = word.toLowerCase().replace(/\s+/g, ' ');
|
|
956
|
+
if (w === 'where')
|
|
957
|
+
return 'filter';
|
|
958
|
+
if (w === 'order by')
|
|
959
|
+
return 'sort';
|
|
960
|
+
if (w === 'select')
|
|
961
|
+
return 'show';
|
|
962
|
+
return w;
|
|
963
|
+
}
|
|
964
|
+
/* ---------- the parse, remembered ----------
|
|
965
|
+
|
|
966
|
+
A parse is a pure function of the text, the schema, `me`, `columnValues`
|
|
967
|
+
and — only when a date phrase is read — `now`. A busy server parses the same
|
|
968
|
+
few hundred texts over and over (every list tab, every dashboard tile), so
|
|
969
|
+
the answer is kept, per schema object (a reloaded App or a new catalog is a
|
|
970
|
+
different object and starts empty) and bounded, least recently used first.
|
|
971
|
+
|
|
972
|
+
`now` is left out of the key until a condition actually reads it. A phrase
|
|
973
|
+
that resolves by calendar day (`today`, `last 30 days`) is kept under the
|
|
974
|
+
local day, which is how `startOfDay` reads it, so a cached `today` is
|
|
975
|
+
re-resolved when the day turns. One that resolves to the millisecond
|
|
976
|
+
(`last 2 hours`, `last 30 minutes`) is never kept. The result is frozen: it is shared. */
|
|
977
|
+
const PARSE_KEPT = 512;
|
|
978
|
+
const NO_SCHEMA = {};
|
|
979
|
+
const parsesBySchema = new WeakMap();
|
|
980
|
+
/** Set by `compileCondition` when a date phrase resolved: 1 by day, 2 by the millisecond. */
|
|
981
|
+
let nowRead = 0;
|
|
982
|
+
let watching;
|
|
983
|
+
/** `work`'s value, and how finely it read the clock (0 not, 1 by day, 2 the instant) and whether it read the reader. */
|
|
984
|
+
export function readsOf(work) {
|
|
985
|
+
const outer = watching;
|
|
986
|
+
const reads = (watching = { clock: 0, reader: false });
|
|
987
|
+
try {
|
|
988
|
+
return { value: work(), ...reads };
|
|
989
|
+
}
|
|
990
|
+
finally {
|
|
991
|
+
watching = outer;
|
|
992
|
+
if (outer)
|
|
993
|
+
noteRead(reads.clock, reads.reader);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/** A read of the clock (1 the day, 2 the instant) or of the reader, told to whatever work is watching (readsOf). */
|
|
997
|
+
export function noteRead(clock, reader = false) {
|
|
998
|
+
if (!watching)
|
|
999
|
+
return;
|
|
1000
|
+
if (clock > watching.clock)
|
|
1001
|
+
watching.clock = clock;
|
|
1002
|
+
if (reader)
|
|
1003
|
+
watching.reader = true;
|
|
1004
|
+
}
|
|
1005
|
+
function freezeDeep(value) {
|
|
1006
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
1007
|
+
Object.freeze(value);
|
|
1008
|
+
for (const inner of Object.values(value))
|
|
1009
|
+
freezeDeep(inner);
|
|
1010
|
+
}
|
|
1011
|
+
return value;
|
|
1012
|
+
}
|
|
1013
|
+
function dayKey(base, now) {
|
|
1014
|
+
const at = now ?? new Date();
|
|
1015
|
+
return `${base}\0${at.getFullYear()}-${at.getMonth()}-${at.getDate()}`;
|
|
1016
|
+
}
|
|
1017
|
+
export function parseQuery(input, options = {}) {
|
|
1018
|
+
// States handed in beside the text are rare (a board's lanes) and never kept: the cache is keyed by the catalog.
|
|
1019
|
+
if (options.states) {
|
|
1020
|
+
nowRead = 0;
|
|
1021
|
+
const result = freezeDeep(parseUncached(input, options));
|
|
1022
|
+
noteRead(nowRead);
|
|
1023
|
+
return result;
|
|
1024
|
+
}
|
|
1025
|
+
const schemaKey = options.schema ?? NO_SCHEMA;
|
|
1026
|
+
let kept = parsesBySchema.get(schemaKey);
|
|
1027
|
+
if (!kept)
|
|
1028
|
+
parsesBySchema.set(schemaKey, (kept = new Map()));
|
|
1029
|
+
const base = `${input}\0${options.columnValues === true ? 1 : 0}\0${options.me === undefined ? '' : JSON.stringify(options.me)}`;
|
|
1030
|
+
const marker = kept.get(base);
|
|
1031
|
+
const key = marker && 'dated' in marker ? dayKey(base, options.now) : base;
|
|
1032
|
+
const hit = marker && 'dated' in marker ? kept.get(key) : marker;
|
|
1033
|
+
if (hit && !('dated' in hit)) {
|
|
1034
|
+
kept.delete(key);
|
|
1035
|
+
kept.set(key, hit);
|
|
1036
|
+
noteRead(marker && 'dated' in marker ? 1 : 0);
|
|
1037
|
+
return hit;
|
|
1038
|
+
}
|
|
1039
|
+
nowRead = 0;
|
|
1040
|
+
const result = freezeDeep(parseUncached(input, options));
|
|
1041
|
+
const used = nowRead;
|
|
1042
|
+
noteRead(used);
|
|
1043
|
+
if (used !== 2) {
|
|
1044
|
+
if (used === 1)
|
|
1045
|
+
kept.set(base, { dated: true });
|
|
1046
|
+
const at = used === 1 ? dayKey(base, options.now) : base;
|
|
1047
|
+
kept.set(at, result);
|
|
1048
|
+
while (kept.size > PARSE_KEPT)
|
|
1049
|
+
kept.delete(kept.keys().next().value);
|
|
1050
|
+
}
|
|
1051
|
+
return result;
|
|
1052
|
+
}
|
|
1053
|
+
function parseUncached(input, options) {
|
|
1054
|
+
const text = input.trim();
|
|
1055
|
+
const now = options.now ?? new Date();
|
|
1056
|
+
if (!text)
|
|
1057
|
+
return { errors: [{ message: 'Start with a table name.' }] };
|
|
1058
|
+
const errors = [];
|
|
1059
|
+
/* Clause keywords are found in a copy with the *insides* of quoted values
|
|
1060
|
+
blanked out, and then used as offsets into the real text.
|
|
1061
|
+
|
|
1062
|
+
Scanning the raw string made a value that happens to contain a clause word
|
|
1063
|
+
into a clause: `name = "order by"` split at the quote, and the error came
|
|
1064
|
+
back "\"\"\" is not a column on customer. Did you mean \"id\"?" — confident,
|
|
1065
|
+
specific, and about a column nobody typed. The blanking preserves length
|
|
1066
|
+
and the quote characters themselves, so every offset still lines up with
|
|
1067
|
+
the original and error positions stay honest. */
|
|
1068
|
+
const scannable = maskQuoted(text);
|
|
1069
|
+
CLAUSE_RE.lastIndex = 0;
|
|
1070
|
+
const marks = [];
|
|
1071
|
+
let m;
|
|
1072
|
+
while ((m = CLAUSE_RE.exec(scannable))) {
|
|
1073
|
+
if (FOLLOWED_BY_OPERATOR.test(scannable.slice(CLAUSE_RE.lastIndex)))
|
|
1074
|
+
continue;
|
|
1075
|
+
/* A clause word inside an unquoted hyphenated value — `status = no-show` —
|
|
1076
|
+
is the value's, not a clause: taken as one, the query read `status = no-`
|
|
1077
|
+
and nothing more, or said the conditions after it were "not a column". */
|
|
1078
|
+
const glued = hyphenatedValue(scannable, m.index, CLAUSE_RE.lastIndex);
|
|
1079
|
+
if (glued) {
|
|
1080
|
+
const word = text.slice(glued.start, glued.end);
|
|
1081
|
+
const lead = /([\w.]+\s*(?:!=|>=|<=|=|>|<|\bcontains\b|\bstartswith\b|\bendswith\b|\blike\b))\s*$/i.exec(text.slice(0, glued.start))?.[1];
|
|
1082
|
+
errors.push({
|
|
1083
|
+
message: `"${word}" is a value, and unquoted its "${m[1]}" starts a clause: quote it — ${lead ? `${lead} ` : ''}"${word}".`,
|
|
1084
|
+
at: glued.start,
|
|
1085
|
+
});
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
marks.push({ keyword: normalizeClause(m[1]), start: m.index, end: CLAUSE_RE.lastIndex });
|
|
1089
|
+
}
|
|
1090
|
+
const head = (marks.length ? text.slice(0, marks[0].start) : text).trim();
|
|
1091
|
+
const tableName = head.split(/\s+/)[0] ?? '';
|
|
1092
|
+
if (!tableName)
|
|
1093
|
+
return { errors: [{ message: 'Which table?' }] };
|
|
1094
|
+
/* The table has to resolve before anything else can be checked, since every
|
|
1095
|
+
column error is relative to it. An unqualified name is matched on the
|
|
1096
|
+
bare table name too, so `customers` finds `public.customers`. */
|
|
1097
|
+
let table;
|
|
1098
|
+
if (options.schema) {
|
|
1099
|
+
table = findTable(options.schema, tableName);
|
|
1100
|
+
if (!table) {
|
|
1101
|
+
const candidates = options.schema.tables.filter((t) => t.name === tableName);
|
|
1102
|
+
if (candidates.length === 1)
|
|
1103
|
+
table = candidates[0];
|
|
1104
|
+
else if (candidates.length > 1) {
|
|
1105
|
+
errors.push({
|
|
1106
|
+
message: `"${tableName}" is ambiguous — it exists in ${candidates.map((c) => c.schema).join(', ')}. Qualify it, e.g. ${candidates[0].id}.`,
|
|
1107
|
+
at: 0,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
const near = options.schema.tables
|
|
1112
|
+
.map((t) => ({ id: t.id, d: Math.min(distance(tableName, t.name), distance(tableName, t.id)) }))
|
|
1113
|
+
.sort((a, b) => a.d - b.d)[0];
|
|
1114
|
+
errors.push({
|
|
1115
|
+
message: `No table called "${tableName}".${near && near.d <= 3 ? ` Did you mean "${near.id}"?` : ''}`,
|
|
1116
|
+
at: 0,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
// Anything after the table name and before the first clause word is already
|
|
1122
|
+
// a filter. `where` stays as optional sugar, but making it mandatory would
|
|
1123
|
+
// be ceremony with nothing to disambiguate.
|
|
1124
|
+
const implied = head.slice(tableName.length).trim();
|
|
1125
|
+
const clauses = {};
|
|
1126
|
+
const clauseAt = {};
|
|
1127
|
+
if (implied) {
|
|
1128
|
+
clauses.filter = implied;
|
|
1129
|
+
clauseAt.filter = tableName.length + 1;
|
|
1130
|
+
}
|
|
1131
|
+
marks.forEach((mark, i) => {
|
|
1132
|
+
const bodyEnd = i + 1 < marks.length ? marks[i + 1].start : text.length;
|
|
1133
|
+
const body = text.slice(mark.end, bodyEnd).trim();
|
|
1134
|
+
if (clauses[mark.keyword] === undefined) {
|
|
1135
|
+
clauses[mark.keyword] = body;
|
|
1136
|
+
clauseAt[mark.keyword] = mark.end;
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
// Repeated conditions concatenate: `where a where b` is `a and b`, which
|
|
1140
|
+
// is also how the implied filter and an explicit `where` are joined.
|
|
1141
|
+
if (mark.keyword === 'filter') {
|
|
1142
|
+
clauses.filter = `${clauses.filter} and ${body}`;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
/* Everything else refuses. Located at the repeat rather than the first
|
|
1146
|
+
one, because that is the word that made the query ambiguous — and the
|
|
1147
|
+
first clause is left standing, so the partial result the editor shows
|
|
1148
|
+
is still something that was typed. */
|
|
1149
|
+
errors.push({ message: repeatedClause(mark.keyword), at: mark.start });
|
|
1150
|
+
});
|
|
1151
|
+
const filter = { groups: [] };
|
|
1152
|
+
/* A segment's name is written out before the conditions are read (expandSegments), and an error inside what
|
|
1153
|
+
it wrote is said at the name, where the author can see it. */
|
|
1154
|
+
let said = (at) => at;
|
|
1155
|
+
if (clauses.filter && table?.segments) {
|
|
1156
|
+
const base = clauseAt.filter ?? 0;
|
|
1157
|
+
const expanded = expandSegments(clauses.filter, table.segments);
|
|
1158
|
+
if (expanded.error) {
|
|
1159
|
+
errors.push({ message: expanded.error.message, at: base + (expanded.error.at ?? 0) });
|
|
1160
|
+
clauses.filter = '';
|
|
1161
|
+
}
|
|
1162
|
+
else if (expanded.text !== clauses.filter) {
|
|
1163
|
+
clauses.filter = expanded.text;
|
|
1164
|
+
said = (at) => base + expanded.at(at - base);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (clauses.filter) {
|
|
1168
|
+
const tree = parseExpression(clauses.filter, clauseAt.filter ?? 0);
|
|
1169
|
+
const groups = toGroups(tree);
|
|
1170
|
+
if (groups.length > MAX_GROUPS) {
|
|
1171
|
+
errors.push({ message: `That expands to more than ${MAX_GROUPS} OR branches. Simplify it or filter in two steps.` });
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
for (const group of groups) {
|
|
1175
|
+
const conditions = [];
|
|
1176
|
+
for (const node of group) {
|
|
1177
|
+
if (node.kind !== 'cond')
|
|
1178
|
+
continue;
|
|
1179
|
+
const { conditions: got, error } = compileCondition(node.text, {
|
|
1180
|
+
table,
|
|
1181
|
+
now,
|
|
1182
|
+
columnValues: options.columnValues === true,
|
|
1183
|
+
...(options.me !== undefined ? { me: options.me } : {}),
|
|
1184
|
+
...(options.states ? { states: options.states } : {}),
|
|
1185
|
+
});
|
|
1186
|
+
if (error)
|
|
1187
|
+
errors.push({ message: error, at: said(node.at) });
|
|
1188
|
+
else if (got)
|
|
1189
|
+
conditions.push(...got);
|
|
1190
|
+
}
|
|
1191
|
+
if (conditions.length)
|
|
1192
|
+
filter.groups.push(conditions);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const orderBy = [];
|
|
1197
|
+
if (clauses.sort) {
|
|
1198
|
+
for (const part of clauses.sort.split(/\s*,\s*|\s+and\s+/i)) {
|
|
1199
|
+
const [field, dir] = part.trim().split(/\s+/);
|
|
1200
|
+
if (!field)
|
|
1201
|
+
continue;
|
|
1202
|
+
const { column, error } = resolveColumn(table, field);
|
|
1203
|
+
if (error)
|
|
1204
|
+
errors.push({ message: error, at: clauseAt.sort });
|
|
1205
|
+
else
|
|
1206
|
+
orderBy.push({ column: column?.name ?? field, direction: (dir ?? '').toLowerCase() === 'desc' ? 'desc' : 'asc' });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
const columns = [];
|
|
1210
|
+
for (const raw of (clauses.show ?? '').split(',')) {
|
|
1211
|
+
const field = raw.trim();
|
|
1212
|
+
if (!field)
|
|
1213
|
+
continue;
|
|
1214
|
+
const { column, error } = resolveColumn(table, field);
|
|
1215
|
+
if (error)
|
|
1216
|
+
errors.push({ message: error, at: clauseAt.show });
|
|
1217
|
+
else
|
|
1218
|
+
columns.push(column?.name ?? field);
|
|
1219
|
+
}
|
|
1220
|
+
let limit;
|
|
1221
|
+
if (clauses.limit) {
|
|
1222
|
+
const n = Number(clauses.limit.split(/\s+/)[0]);
|
|
1223
|
+
if (Number.isFinite(n) && n > 0)
|
|
1224
|
+
limit = Math.min(Math.floor(n), MAX_LIMIT);
|
|
1225
|
+
else
|
|
1226
|
+
errors.push({ message: `limit expects a number, got "${clauses.limit}"`, at: clauseAt.limit });
|
|
1227
|
+
}
|
|
1228
|
+
/* A query is returned even when there are errors, so the editor can show a
|
|
1229
|
+
partial result while someone is still typing. The caller decides whether
|
|
1230
|
+
errors are fatal; only a missing table makes the query unusable. */
|
|
1231
|
+
if (!tableName)
|
|
1232
|
+
return { errors };
|
|
1233
|
+
return {
|
|
1234
|
+
query: { table: table?.id ?? tableName, filter, columns, orderBy, limit },
|
|
1235
|
+
errors,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Render a parsed query back as the SQL it will become.
|
|
1240
|
+
*
|
|
1241
|
+
* This is for the explain panel, not for execution — the adapter builds the
|
|
1242
|
+
* real statement from the same `Filter` with bound parameters. Showing values
|
|
1243
|
+
* inline here is safe because the string never reaches a database, and it is
|
|
1244
|
+
* the whole point: seeing `country = $1` next to `country = 'Australia'` is
|
|
1245
|
+
* how someone learns what the tool is doing on their behalf.
|
|
1246
|
+
*/
|
|
1247
|
+
export function explain(query) {
|
|
1248
|
+
const cols = query.columns.length ? query.columns.join(', ') : '*';
|
|
1249
|
+
const lines = [`SELECT ${cols}`, ` FROM ${query.table}`];
|
|
1250
|
+
if (query.filter.groups.length) {
|
|
1251
|
+
const groups = query.filter.groups.map((g) => g
|
|
1252
|
+
.map((c) => {
|
|
1253
|
+
if (c.op === 'isNull')
|
|
1254
|
+
return `${c.column} IS NULL`;
|
|
1255
|
+
if (c.op === 'isNotNull')
|
|
1256
|
+
return `${c.column} IS NOT NULL`;
|
|
1257
|
+
if (c.other !== undefined)
|
|
1258
|
+
return `${c.column} ${c.op} ${c.other}`;
|
|
1259
|
+
if (c.op === 'in') {
|
|
1260
|
+
const list = (Array.isArray(c.value) ? c.value : [c.value]).map(literal).join(', ');
|
|
1261
|
+
return `${c.column} IN (${list})`;
|
|
1262
|
+
}
|
|
1263
|
+
if (c.op === 'has') {
|
|
1264
|
+
const list = (Array.isArray(c.value) ? c.value : [c.value]).map(literal).join(', ');
|
|
1265
|
+
return `${c.column} HAS (${list})`;
|
|
1266
|
+
}
|
|
1267
|
+
if (c.op === 'like') {
|
|
1268
|
+
/* Shown with its ESCAPE clause always, unlike the searches above.
|
|
1269
|
+
There the clause is plumbing behind a plain search; here the
|
|
1270
|
+
escape character is part of the operator someone chose to write
|
|
1271
|
+
by hand, and the preview is where they find out it exists. */
|
|
1272
|
+
return `${c.column} LIKE ${literal(String(c.value ?? ''))} ESCAPE '${LIKE_ESCAPE}'`;
|
|
1273
|
+
}
|
|
1274
|
+
if (c.op === 'contains' || c.op === 'startsWith' || c.op === 'endsWith') {
|
|
1275
|
+
const pattern = likePattern(c.value, c.op);
|
|
1276
|
+
/* The ESCAPE clause is shown only when the value needed escaping.
|
|
1277
|
+
It is always sent — but printing it on every plain search would
|
|
1278
|
+
put dialect plumbing in front of the thing this preview exists
|
|
1279
|
+
to teach, and printing the escaped pattern *without* it would
|
|
1280
|
+
be a statement that means something else. */
|
|
1281
|
+
const escape = pattern.includes(LIKE_ESCAPE) ? ` ESCAPE '${LIKE_ESCAPE}'` : '';
|
|
1282
|
+
return `${c.column} LIKE ${literal(pattern)}${escape}`;
|
|
1283
|
+
}
|
|
1284
|
+
return `${c.column} ${c.op} ${literal(c.value)}`;
|
|
1285
|
+
})
|
|
1286
|
+
.join(' AND '));
|
|
1287
|
+
lines.push(` WHERE ${groups.length === 1 ? groups[0] : groups.map((g) => `(${g})`).join('\n OR ')}`);
|
|
1288
|
+
}
|
|
1289
|
+
if (query.orderBy.length) {
|
|
1290
|
+
lines.push(` ORDER BY ${query.orderBy.map((o) => `${o.column} ${o.direction.toUpperCase()}`).join(', ')}`);
|
|
1291
|
+
}
|
|
1292
|
+
if (query.limit !== undefined)
|
|
1293
|
+
lines.push(` LIMIT ${query.limit}`);
|
|
1294
|
+
return lines.join('\n');
|
|
1295
|
+
}
|
|
1296
|
+
function literal(value) {
|
|
1297
|
+
if (value === null || value === undefined)
|
|
1298
|
+
return 'NULL';
|
|
1299
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
1300
|
+
return String(value);
|
|
1301
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
1302
|
+
}
|