@kensio/yulin 1.20.14 → 1.20.16

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.
Files changed (154) hide show
  1. package/README.md +13 -0
  2. package/{dist/config/oxlint/cffjs2.oxlintrc.json → cffjs2.oxlintrc.json} +1 -1
  3. package/dist/config/oxlint/cffjs2.oxlint.config.js +4 -3
  4. package/dist/sdk/module/sim-sdk-module-client-interceptor.d.ts +5 -0
  5. package/dist/sdk/module/sim-sdk-module-client-interceptor.js +14 -0
  6. package/dist/sdk/module/sim-sdk-static-client-factory.d.ts +26 -0
  7. package/dist/sdk/module/sim-sdk-static-client-factory.js +59 -0
  8. package/dist/sdk/send-patch.d.ts +8 -0
  9. package/dist/sdk/send-patch.js +11 -0
  10. package/dist/service/aws/caller/sim-aws-caller-resolver.d.ts +28 -6
  11. package/dist/service/aws/caller/sim-aws-caller-resolver.js +18 -11
  12. package/dist/service/aws/caller/sim-aws-caller.d.ts +9 -0
  13. package/dist/service/aws/caller/sim-aws-resolved-caller.d.ts +19 -0
  14. package/dist/service/aws/caller/sim-aws-resolved-caller.js +26 -0
  15. package/dist/service/aws/factory/sim-aws-account-region-service-builder.d.ts +6 -0
  16. package/dist/service/aws/factory/sim-aws-account-region-service-builder.js +3 -0
  17. package/dist/service/aws/factory/sim-aws-account-service-cache.d.ts +6 -0
  18. package/dist/service/aws/factory/sim-aws-account-service-cache.js +5 -0
  19. package/dist/service/aws/factory/sim-aws-service-factory.d.ts +5 -0
  20. package/dist/service/aws/factory/sim-aws-service-factory.js +6 -0
  21. package/dist/service/aws/sim-aws-properties.d.ts +15 -0
  22. package/dist/service/aws/sim-aws.js +2 -1
  23. package/dist/service/cloudformation/resource/resolve/property/sim-cfn-resource-property-resolver.d.ts +11 -4
  24. package/dist/service/cloudformation/resource/resolve/property/sim-cfn-resource-property-resolver.js +21 -8
  25. package/dist/service/cloudformation/resource/sim-cfn-resource.type.d.ts +9 -0
  26. package/dist/service/cloudformation/template/dynamic/make-sim-cfn-dynamic-references.d.ts +2 -0
  27. package/dist/service/cloudformation/template/dynamic/make-sim-cfn-dynamic-references.js +2 -2
  28. package/dist/service/cloudformation/template/dynamic/sim-cfn-dynamic-reference-resolvers.d.ts +10 -0
  29. package/dist/service/cloudformation/template/dynamic/sim-cfn-dynamic-reference-resolvers.js +4 -3
  30. package/dist/service/cloudformation/template/dynamic/sim-cfn-dynamic-reference-scan.d.ts +11 -0
  31. package/dist/service/cloudformation/template/dynamic/sim-cfn-dynamic-reference-scan.js +21 -0
  32. package/dist/service/cloudformation/template/dynamic/sim-cfn-dynamic-references.d.ts +24 -2
  33. package/dist/service/cloudformation/template/dynamic/sim-cfn-dynamic-references.js +50 -11
  34. package/dist/service/cloudformation/template/dynamic/sim-cfn-prefetched-dynamic-references.d.ts +42 -0
  35. package/dist/service/cloudformation/template/dynamic/sim-cfn-prefetched-dynamic-references.js +80 -0
  36. package/dist/service/eventbridge/delivery/sim-event-bridge-delivery-failures.d.ts +16 -0
  37. package/dist/service/eventbridge/delivery/sim-event-bridge-delivery-failures.js +19 -0
  38. package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.d.ts +30 -7
  39. package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.js +20 -11
  40. package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.d.ts +2 -1
  41. package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.js +5 -1
  42. package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.d.ts +10 -0
  43. package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.js +15 -3
  44. package/dist/service/iam/authorize/sim-iam-account-auth-z.d.ts +11 -4
  45. package/dist/service/iam/authorize/sim-iam-account-auth-z.js +5 -3
  46. package/dist/service/iam/authorize/sim-iam-authorizer.d.ts +2 -1
  47. package/dist/service/iam/authorize/sim-iam-authorizer.js +1 -0
  48. package/dist/service/iam/command/policy/create-policy/create-policy-input-resolver.js +4 -1
  49. package/dist/service/iam/command/policy/put-role-policy/put-role-policy.handler.js +5 -1
  50. package/dist/service/iam/command/policy/put-user-policy/put-user-policy.handler.js +5 -1
  51. package/dist/service/iam/policy/parse/sim-iam-document-parser.d.ts +4 -1
  52. package/dist/service/iam/policy/parse/sim-iam-document-parser.js +13 -15
  53. package/dist/service/iam/policy/sim-iam-statement-label.d.ts +28 -0
  54. package/dist/service/iam/policy/sim-iam-statement-label.js +17 -0
  55. package/dist/service/iam/policy/sim-iam-statement-strings.d.ts +11 -0
  56. package/dist/service/iam/policy/sim-iam-statement-strings.js +27 -0
  57. package/dist/service/iam/role/sim-iam-role-with-policy.factory.d.ts +9 -0
  58. package/dist/service/iam/role/sim-iam-role-with-policy.factory.js +4 -2
  59. package/dist/service/iam/sim-iam-account-parts.d.ts +8 -0
  60. package/dist/service/iam/sim-iam-account-parts.js +1 -0
  61. package/dist/service/iam/validate/sim-iam-policy-document-validator.d.ts +9 -2
  62. package/dist/service/iam/validate/sim-iam-policy-document-validator.js +37 -19
  63. package/dist/service/lambda/function/code/sim-lambda-vm-zip-function.factory.d.ts +7 -0
  64. package/dist/service/lambda/function/code/sim-lambda-vm-zip-function.factory.js +1 -0
  65. package/dist/service/lambda/function/code/vm/sdk/sim-lambda-provided-sdk-module.d.ts +19 -0
  66. package/dist/service/lambda/function/code/vm/sdk/sim-lambda-provided-sdk-module.js +78 -0
  67. package/dist/service/lambda/function/code/vm/sdk/sim-lambda-sdk-packages-not-installed.error.d.ts +15 -0
  68. package/dist/service/lambda/function/code/vm/sdk/sim-lambda-sdk-packages-not-installed.error.js +27 -0
  69. package/dist/service/lambda/function/code/vm/sdk/sim-lambda-vm-sdk-module-provider.d.ts +9 -0
  70. package/dist/service/lambda/function/code/vm/sdk/sim-sdk-lambda-vm-module-provider.d.ts +10 -0
  71. package/dist/service/lambda/function/code/vm/sdk/sim-sdk-lambda-vm-module-provider.js +28 -34
  72. package/dist/service/lambda/function/code/vm/sim-lambda-host-modules.d.ts +15 -0
  73. package/dist/service/lambda/function/code/vm/sim-lambda-host-modules.js +31 -0
  74. package/dist/service/lambda/function/code/vm/sim-lambda-vm-module-resolver.d.ts +7 -0
  75. package/dist/service/lambda/function/code/vm/sim-lambda-vm-module-resolver.js +15 -0
  76. package/dist/service/lambda/function/code/vm/sim-lambda-vm-modules.js +5 -4
  77. package/dist/service/s3/command/put-bucket-policy/put-bucket-policy.handler.js +4 -1
  78. package/dist/service/scheduler/delivery/sim-scheduler-delivery-failures.d.ts +16 -0
  79. package/dist/service/scheduler/delivery/sim-scheduler-delivery-failures.js +19 -0
  80. package/dist/service/scheduler/index.d.ts +1 -1
  81. package/dist/service/scheduler/index.js +1 -1
  82. package/dist/service/secretsmanager/cfn/dynamic/sim-cfn-secrets-manager-dynamic-reference-resolver.d.ts +13 -15
  83. package/dist/service/secretsmanager/cfn/dynamic/sim-cfn-secrets-manager-dynamic-reference-resolver.js +22 -43
  84. package/dist/service/secretsmanager/cfn/dynamic/sim-cfn-secrets-manager-reference-stand-in.d.ts +9 -0
  85. package/dist/service/secretsmanager/cfn/dynamic/sim-cfn-secrets-manager-reference-stand-in.js +14 -0
  86. package/dist/service/secretsmanager/cfn/dynamic/sim-cfn-secrets-manager-reference-value.d.ts +12 -0
  87. package/dist/service/secretsmanager/cfn/dynamic/sim-cfn-secrets-manager-reference-value.js +29 -0
  88. package/dist/service/ssm/cfn/dynamic/sim-cfn-ssm-dynamic-reference-resolver.d.ts +22 -6
  89. package/dist/service/ssm/cfn/dynamic/sim-cfn-ssm-dynamic-reference-resolver.js +39 -24
  90. package/dist/service/ssm/cfn/dynamic/sim-cfn-ssm-secure-dynamic-reference-resolver.d.ts +10 -5
  91. package/dist/service/ssm/cfn/dynamic/sim-cfn-ssm-secure-dynamic-reference-resolver.js +10 -8
  92. package/dist/service/ssm/sim-ssm.d.ts +10 -2
  93. package/dist/service/ssm/sim-ssm.js +12 -4
  94. package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.d.ts +9 -2
  95. package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.js +2 -2
  96. package/dist/service/sts/auth-z/assume-role-source-account-auth-z.d.ts +10 -4
  97. package/dist/service/sts/auth-z/assume-role-source-account-auth-z.js +8 -7
  98. package/dist/service/sts/auth-z/assume-role-target-auth-z.d.ts +2 -2
  99. package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.d.ts +6 -2
  100. package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.js +14 -11
  101. package/dist/service/sts/command/assume-role/assume-role.handler.d.ts +5 -1
  102. package/dist/service/sts/command/assume-role/assume-role.handler.js +5 -2
  103. package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.d.ts +5 -1
  104. package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.js +4 -1
  105. package/dist/service/sts/service-role/sim-service-role.js +5 -1
  106. package/dist/service/sts/sim-sts.d.ts +9 -1
  107. package/dist/service/sts/sim-sts.js +4 -0
  108. package/docs/README.md +54 -0
  109. package/docs/ai-skill/README.md +69 -0
  110. package/docs/cli/README.md +284 -0
  111. package/docs/factories/README.md +140 -0
  112. package/docs/lint/README.md +176 -0
  113. package/docs/non-aws-dependencies/README.md +261 -0
  114. package/docs/sdk/README.md +272 -0
  115. package/docs/serve/README.md +1126 -0
  116. package/docs/services/acm/README.md +823 -0
  117. package/docs/services/apigateway/README.md +1919 -0
  118. package/docs/services/apigatewayv2/README.md +2678 -0
  119. package/docs/services/athena/README.md +1044 -0
  120. package/docs/services/bedrock/README.md +350 -0
  121. package/docs/services/cloudformation/README.md +3440 -0
  122. package/docs/services/cloudfront/README.md +2874 -0
  123. package/docs/services/cloudwatch/README.md +420 -0
  124. package/docs/services/cognito/README.md +4923 -0
  125. package/docs/services/dynamodb/README.md +3784 -0
  126. package/docs/services/ecr/README.md +302 -0
  127. package/docs/services/ecs/README.md +2010 -0
  128. package/docs/services/elbv2/README.md +1897 -0
  129. package/docs/services/eventbridge/README.md +1110 -0
  130. package/docs/services/firehose/README.md +805 -0
  131. package/docs/services/glue/README.md +501 -0
  132. package/docs/services/iam/README.md +1283 -0
  133. package/docs/services/kinesis/README.md +454 -0
  134. package/docs/services/kms/README.md +659 -0
  135. package/docs/services/lambda/README.md +3645 -0
  136. package/docs/services/logs/README.md +868 -0
  137. package/docs/services/organizations/README.md +574 -0
  138. package/docs/services/personalize/README.md +835 -0
  139. package/docs/services/rekognition/README.md +1070 -0
  140. package/docs/services/route53/README.md +1783 -0
  141. package/docs/services/s3/README.md +3024 -0
  142. package/docs/services/scheduler/README.md +806 -0
  143. package/docs/services/secretsmanager/README.md +667 -0
  144. package/docs/services/ses/README.md +1011 -0
  145. package/docs/services/sns/README.md +1904 -0
  146. package/docs/services/sqs/README.md +1185 -0
  147. package/docs/services/ssm/README.md +1072 -0
  148. package/docs/services/stepfunctions/README.md +1332 -0
  149. package/docs/services/sts/README.md +245 -0
  150. package/docs/services/wafv2/README.md +1378 -0
  151. package/docs/terraform/README.md +248 -0
  152. package/docs/time/README.md +284 -0
  153. package/llms.txt +59 -0
  154. package/package.json +9 -5
@@ -0,0 +1,1044 @@
1
+ # Simulated Athena
2
+
3
+ Yulin includes a simulated Amazon Athena for tests and local development. It holds workgroups and
4
+ named queries, and hands both back through the SDK.
5
+
6
+ A query is answered one of two ways. A test declares what it answers with, and the simulation
7
+ matches that declaration on the query text. Or the
8
+ [query engine](#running-a-query-for-real) runs the SQL for real over the objects a test seeded into
9
+ simulated S3. The engine is off until a test turns it on, and it needs one package added to the
10
+ project.
11
+
12
+ Either way the lifecycle around the query is real. A test can prove its bytes-scanned cutoff
13
+ refuses a query, that results land where the workgroup says, and that a client polls the lifecycle
14
+ correctly. The tables a query names are looked for in the simulated
15
+ [Glue Data Catalog](https://yulinsim.dev/services/glue/ "Simulated Glue usage docs"), and a query
16
+ naming one that is absent fails the way real Athena fails it. The
17
+ [Limitations](#limitations) at the end say what this leaves out.
18
+
19
+ Athena-specific types are imported from the `@kensio/yulin/athena` subpath.
20
+
21
+ ## Workgroups from a template
22
+
23
+ `AWS::Athena::WorkGroup` deploys like any other supported Resource type. `Ref` answers with the
24
+ workgroup name and `Fn::GetAtt CreationTime` with when it was made.
25
+
26
+ ```typescript sim-athena-cloudformation-work-group
27
+ /**
28
+ * An AWS::Athena::WorkGroup deployed from a template and read back.
29
+ */
30
+
31
+ import { AthenaClient, GetWorkGroupCommand } from "@aws-sdk/client-athena";
32
+
33
+ import { SimAws } from "@kensio/yulin";
34
+ import { SimSdk } from "@kensio/yulin/sdk";
35
+
36
+ const simAws = new SimAws();
37
+
38
+ const stack = await simAws.cloudFormation().deployTemplate({
39
+ stackName: "rainlytics",
40
+ template: {
41
+ Resources: {
42
+ Queries: {
43
+ Type: "AWS::Athena::WorkGroup",
44
+ Properties: {
45
+ Name: "rainlytics",
46
+ Description: "CloudFront access log queries",
47
+ WorkGroupConfiguration: {
48
+ BytesScannedCutoffPerQuery: 10_000_000_000,
49
+ EnforceWorkGroupConfiguration: true,
50
+ ResultConfiguration: {
51
+ OutputLocation: "s3://rainlytics-results/queries/",
52
+ },
53
+ },
54
+ },
55
+ },
56
+ },
57
+ },
58
+ });
59
+
60
+ await stack.waitForDeployComplete();
61
+
62
+ using simSdk = new SimSdk({ simAws });
63
+ simSdk.intercept(AthenaClient);
64
+
65
+ const athena = new AthenaClient({});
66
+ const read = await athena.send(
67
+ new GetWorkGroupCommand({ WorkGroup: "rainlytics" }),
68
+ );
69
+
70
+ // 10000000000
71
+ console.log(read.WorkGroup?.Configuration?.BytesScannedCutoffPerQuery);
72
+ ```
73
+
74
+ The properties this simulation reads are `Name`, `Description`, `State` and, under
75
+ `WorkGroupConfiguration`, `BytesScannedCutoffPerQuery`, `EnforceWorkGroupConfiguration`,
76
+ `PublishCloudWatchMetricsEnabled`, `RequesterPaysEnabled`, `ResultConfiguration` and `EngineVersion`.
77
+
78
+ `CreateWorkGroup` has no state field. A workgroup a template disables is created and then updated,
79
+ and the state reads back either way.
80
+
81
+ ## Named queries from a template
82
+
83
+ `AWS::Athena::NamedQuery` saves SQL under a name. A named query belongs to a workgroup, and one
84
+ naming no workgroup goes in `primary`.
85
+
86
+ ```typescript sim-athena-cloudformation-named-query
87
+ /**
88
+ * An AWS::Athena::NamedQuery registering a rollup against a workgroup.
89
+ */
90
+
91
+ import { SimAws } from "@kensio/yulin";
92
+
93
+ const simAws = new SimAws();
94
+
95
+ const stack = await simAws.cloudFormation().deployTemplate({
96
+ stackName: "rainlytics",
97
+ template: {
98
+ Resources: {
99
+ Queries: {
100
+ Type: "AWS::Athena::WorkGroup",
101
+ Properties: { Name: "rainlytics" },
102
+ },
103
+ Pageviews: {
104
+ Type: "AWS::Athena::NamedQuery",
105
+ Properties: {
106
+ Name: "pageviews",
107
+ Database: "rainlytics",
108
+ QueryString:
109
+ "SELECT cs_uri_stem, count(*) FROM access_logs GROUP BY 1",
110
+ WorkGroup: { Ref: "Queries" },
111
+ },
112
+ },
113
+ },
114
+ },
115
+ });
116
+
117
+ await stack.waitForDeployComplete();
118
+
119
+ const listed = await simAws
120
+ .athena()
121
+ .listNamedQueries({ input: { WorkGroup: "rainlytics" } });
122
+
123
+ // 1
124
+ console.log(listed.NamedQueryIds?.length);
125
+ ```
126
+
127
+ A named query naming a workgroup the stack never made fails its Resource. Registering it would
128
+ leave it unreachable, because a listing finds a named query through its workgroup.
129
+
130
+ ## Running a query
131
+
132
+ `StartQueryExecution` queues a query and answers with an id. The execution reaches `RUNNING` and
133
+ then `SUCCEEDED` or `FAILED` on the simulator's background work. A client polling
134
+ `GetQueryExecution` sees each state on the way through.
135
+
136
+ ```typescript sim-athena-query-execution
137
+ /**
138
+ * Declaring what a query answers, running it, and reading the rows back.
139
+ */
140
+
141
+ import { SimAws } from "@kensio/yulin";
142
+
143
+ const simAws = new SimAws();
144
+
145
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-results" } });
146
+ await simAws.athena().createWorkGroup({
147
+ input: {
148
+ Name: "rainlytics",
149
+ Configuration: {
150
+ ResultConfiguration: { OutputLocation: "s3://rainlytics-results/q/" },
151
+ },
152
+ },
153
+ });
154
+
155
+ const sql = "SELECT cs_uri_stem, count(*) FROM access_logs GROUP BY 1";
156
+
157
+ simAws
158
+ .athena()
159
+ .results()
160
+ .onQuery(sql, {
161
+ columns: ["cs_uri_stem", "views"],
162
+ rows: [["/", "4213"]],
163
+ bytesScanned: 2_000_000,
164
+ });
165
+
166
+ const started = await simAws.athena().startQueryExecution({
167
+ input: { QueryString: sql, WorkGroup: "rainlytics" },
168
+ });
169
+
170
+ await simAws.backgroundTasksComplete();
171
+
172
+ const results = await simAws.athena().getQueryResults({
173
+ input: { QueryExecutionId: started.QueryExecutionId },
174
+ });
175
+
176
+ // "4213". The first row holds the column names, as it does on real Athena.
177
+ console.log(results.ResultSet?.Rows?.[1]?.Data?.[1]?.VarCharValue);
178
+ ```
179
+
180
+ A rule for an exact query wins, then a rule for a workgroup, then the default. `onWorkGroup` covers
181
+ every query a stack's rollups run, and `byDefault` covers everything else. Matching is exact, on the
182
+ query text as it was sent, so two queries differing only in whitespace are two different keys.
183
+
184
+ The query engine sits between the two tiers. A rule for an exact query is ahead of it and the
185
+ workgroup rule and the default are behind it.
186
+
187
+ `failsWith` fails a query instead of answering it. Nothing here reads SQL, so a query that should
188
+ fail cannot be discovered on its own. Saying so is what makes a client's failure handling
189
+ reachable.
190
+
191
+ ## Running a query for real
192
+
193
+ The query engine answers a `SELECT` from the objects a test seeded into simulated S3. It reads the
194
+ table's schema out of the Glue Data Catalog, decodes each object with the SerDe the table declares,
195
+ loads the rows into an in-memory SQLite database, and answers the statement from them. Roughly
196
+ nineteen queries in twenty of the shapes a test writes run this way.
197
+
198
+ The engine is off until a test turns it on, and it needs `node-sql-parser` in the project. The
199
+ parser is an optional peer dependency, so a project that never runs a query never installs it.
200
+
201
+ ```bash
202
+ pnpm add -D node-sql-parser
203
+ ```
204
+
205
+ `engine().enable()` turns the engine on and loads the parser. It raises where the package is absent,
206
+ naming what to add.
207
+
208
+ ```typescript sim-athena-query-engine
209
+ /**
210
+ * A query answered from the objects a test seeded, rather than from a
211
+ * declaration.
212
+ */
213
+
214
+ import { SimAws } from "@kensio/yulin";
215
+
216
+ const simAws = new SimAws();
217
+
218
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-logs" } });
219
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-results" } });
220
+ await simAws.athena().createWorkGroup({
221
+ input: {
222
+ Name: "rainlytics",
223
+ Configuration: {
224
+ ResultConfiguration: { OutputLocation: "s3://rainlytics-results/q/" },
225
+ },
226
+ },
227
+ });
228
+
229
+ simAws.glue().createDatabase({
230
+ input: { DatabaseInput: { Name: "rainlytics" } },
231
+ });
232
+ simAws.glue().createTable({
233
+ input: {
234
+ DatabaseName: "rainlytics",
235
+ TableInput: {
236
+ Name: "access_logs",
237
+ PartitionKeys: [{ Name: "day", Type: "string" }],
238
+ StorageDescriptor: {
239
+ Columns: [
240
+ { Name: "url", Type: "string" },
241
+ { Name: "status", Type: "int" },
242
+ { Name: "bytes", Type: "bigint" },
243
+ ],
244
+ Location: "s3://rainlytics-logs/cloudfront/",
245
+ SerdeInfo: {
246
+ SerializationLibrary: "org.openx.data.jsonserde.JsonSerDe",
247
+ },
248
+ },
249
+ },
250
+ },
251
+ });
252
+
253
+ await simAws.s3().putObject({
254
+ input: {
255
+ Bucket: "rainlytics-logs",
256
+ Key: "cloudfront/day=2026-08-01/part-0.json",
257
+ Body: [
258
+ '{"url":"/","status":200,"bytes":1200}',
259
+ '{"url":"/pricing","status":404,"bytes":310}',
260
+ '{"url":"/pricing","status":404,"bytes":305}',
261
+ ].join("\n"),
262
+ },
263
+ });
264
+
265
+ // node-sql-parser has to be in the project for this line to work.
266
+ await simAws.athena().engine().enable();
267
+
268
+ const started = await simAws.athena().startQueryExecution({
269
+ input: {
270
+ QueryString:
271
+ "SELECT url, count(*) AS hits, sum(bytes) AS total " +
272
+ "FROM rainlytics.access_logs WHERE status >= 400 AND day = '2026-08-01' " +
273
+ "GROUP BY url ORDER BY hits DESC",
274
+ WorkGroup: "rainlytics",
275
+ },
276
+ });
277
+
278
+ await simAws.backgroundTasksComplete();
279
+
280
+ const results = await simAws.athena().getQueryResults({
281
+ input: { QueryExecutionId: started.QueryExecutionId },
282
+ });
283
+
284
+ // ["/pricing", "2", "615"], computed from the objects.
285
+ console.log(
286
+ results.ResultSet?.Rows?.[1]?.Data?.map((cell) => cell.VarCharValue),
287
+ );
288
+
289
+ // "engine", which is how a test proves the rows came from the data.
290
+ console.log(simAws.athena().queryExecutions()[0]?.answeredBy);
291
+ ```
292
+
293
+ A declaration written against one exact query text still wins. That is the escape hatch for a
294
+ statement the engine gets wrong, and it is why `results()` is unchanged. Everything the engine turns
295
+ down falls back to the declarations, where a workgroup rule or the default answers it. `answeredBy`
296
+ on the execution says which of the two answered, and a test that wants the engine can assert on it.
297
+
298
+ The engine turns a query down where the parser refuses the statement, where SQLite refuses to run
299
+ it, where a table declares a format it has no reader for, and where an object it needs cannot be
300
+ opened. Every one of those ends the same way, with the declared result answering.
301
+
302
+ ### The objects it reads
303
+
304
+ The SerDe class name in the table's storage descriptor says how its objects are decoded.
305
+
306
+ - `org.openx.data.jsonserde.JsonSerDe`, `org.apache.hive.hcatalog.data.JsonSerDe` and
307
+ `org.apache.hadoop.hive.serde2.JsonSerDe` read JSON lines, one record per line.
308
+ - `org.apache.hadoop.hive.serde2.OpenCSVSerde` reads comma separated text with `"` around a field
309
+ that needs it.
310
+ - `org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe` reads the delimiter `field.delim` names,
311
+ which defaults to the control character Hive uses.
312
+
313
+ `separatorChar`, `quoteChar` and `escapeChar` in the SerDe's parameters override those defaults, and
314
+ `skip.header.line.count` on the table drops the first lines of every object. An empty field reads as
315
+ null, along with Hive's `\N`. A boolean column reads `true`, `false`, `1` and `0`, and any other
316
+ text in one reads as null.
317
+
318
+ A nested object or array is kept as its JSON text, and `json_extract_scalar`, `cardinality` and
319
+ `element_at` reach into it.
320
+
321
+ An object's key says how it is compressed. A key ending `.gz`, `.zst` or `.deflate` is decompressed
322
+ before the SerDe reads it. A CloudFront standard logging table needs that, since every object
323
+ delivered under one is gzipped. Node's own zlib covers those three codecs. A key ending `.bz2`,
324
+ `.bzip2`, `.lz4`, `.lzo` or `.snappy` turns the query down, and the declaration a test wrote answers
325
+ it. Any other key is read as text.
326
+
327
+ A `mapping.<column>` parameter on the OpenX JSON SerDe reads that column from the key it names. A
328
+ CloudFront access log table needs it, since a record arrives keyed by `cs(Referer)` and no Athena
329
+ column can be called that. The key matches a record's key of any case until the table sets
330
+ `case.insensitive` to `FALSE`, and after that it matches as written. A mapped column reads null
331
+ where the record holds no such key, including where the record holds a key of the column's own
332
+ name. The Hive JSON SerDes have no `mapping` property, and their columns read by name.
333
+
334
+ A partition column's value comes from the partition the object sits in. A table projecting its
335
+ partitions takes it from the projection, and a table laid out Hive style under its own location
336
+ takes it from the `key=value` segments of the object's key. Either way the column reads on every
337
+ row, though no object holds it.
338
+
339
+ ### What it answers with
340
+
341
+ Column types come from the Glue schema, written the way Athena writes them, so Hive's `string`
342
+ reports as `varchar` and its `int` as `integer`. A boolean column reads as `true` and `false`. A
343
+ computed column has no schema entry behind it, and its type is read off the first value that is not
344
+ null.
345
+
346
+ Two rewrites keep an answer the same as Athena's. `PRAGMA case_sensitive_like` is set on the
347
+ database, because SQLite matches `LIKE` without regard to case for ASCII and Athena matches it with.
348
+ Every ascending sort is emitted carrying `NULLS LAST`, because Trino orders nulls last whichever
349
+ direction it sorts and SQLite orders them first ascending. Both were cases where a query answered
350
+ differently while still succeeding, which is the failure that costs the most to find.
351
+
352
+ ### Flattening an array or a map
353
+
354
+ `UNNEST` runs. An array or a map column is held as its JSON text, and SQLite reads that with
355
+ `json_each`, so a statement flattening one returns a row per element the way Athena does.
356
+
357
+ ```sql
358
+ SELECT e.id, t.tag
359
+ FROM rainlytics.events e
360
+ CROSS JOIN UNNEST(e.tags) AS t(tag)
361
+ ```
362
+
363
+ An array flattens to one column and a map flattens to two, the key beside the value, as
364
+ `UNNEST(e.attrs) AS t(attribute, value)`. `WITH ORDINALITY` adds the position, counted from one.
365
+ The Glue schema is what says which of the two a column holds, and a column it calls anything else
366
+ falls back rather than reading a scalar as a collection.
367
+
368
+ One flattening per statement is what this covers, joined with `CROSS JOIN`. A second `UNNEST`, a
369
+ `LEFT JOIN UNNEST`, a `SELECT *` beside one, and a position taken from a map all fall back.
370
+
371
+ ### The functions a statement can call
372
+
373
+ SQLite carries a much smaller function library than Trino, and the engine fills the gap for the ones
374
+ a test reaches for. SQLite refuses a function absent from this list, and the query then falls back to
375
+ its declared result.
376
+
377
+ | Family | Functions |
378
+ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
379
+ | Date and time | `current_date`, `current_timestamp`, `date_add`, `date_diff`, `date_trunc`, `date_format`, `at_timezone`, `from_unixtime`, `to_unixtime`, `from_iso8601_timestamp`, `from_iso8601_date`, `to_iso8601` |
380
+ | JSON | `json_extract`, `json_extract_scalar`, `json_parse`, `json_size` |
381
+ | Array and map | `array_agg`, `cardinality`, `contains`, `element_at`, `array_join`, `slice` |
382
+ | String | `regexp_like`, `regexp_extract`, `regexp_replace`, `split_part`, `strpos` |
383
+ | Binary | `md5`, `sha1`, `sha256`, `sha512`, `xxhash64`, `murmur3`, `crc32`, `to_hex`, `from_hex`, `to_base64`, `from_base64`, `to_utf8`, `from_utf8` |
384
+ | URL | `url_extract_host`, `url_extract_path`, `url_extract_protocol`, `url_extract_port`, `url_extract_query`, `url_extract_fragment`, `url_extract_parameter`, `url_decode`, `url_encode` |
385
+ | Approximate | `approx_distinct`, `approx_percentile` |
386
+
387
+ `substr` and `format` are SQLite's own. Both count from one and take the same `%s` and `%d` a
388
+ statement writes, so shadowing either would replace something that works.
389
+
390
+ The hashing functions answer with bytes and `to_hex` writes those bytes in the upper case Trino
391
+ writes them in. Node carries every digest here apart from xxHash64 and MurmurHash3, and both of
392
+ those are written out by hand against their published vectors. Trino refuses text where a
393
+ `varbinary` is wanted, and SQLite has no analysis to refuse it with. A column reaching `sha256`
394
+ with no `to_utf8` around it is hashed as its UTF-8 bytes.
395
+
396
+ `count(DISTINCT <expression>)` runs. The parser's Athena grammar takes a column after `DISTINCT`
397
+ and refuses everything else. The call is rewritten onto an aggregate of the simulator's own before
398
+ the statement is read. A plain `count(DISTINCT <column>)` is left alone and SQLite counts it. A
399
+ call written inside a string literal is left alone as well.
400
+
401
+ `current_date` and `current_timestamp` read the simulator's clock. A test that froze time gets the
402
+ instant it froze, and the same test answers the same way on every machine. Athena reads both at the
403
+ instant the query started, which is what the execution records.
404
+
405
+ A function that cannot answer faithfully raises rather than guessing, and the query falls back.
406
+ `date_add` with a unit Trino does not name, `slice` starting at zero, `array_join` over an array of
407
+ objects, and `regexp_extract` naming a capture group the pattern has not got all land there. A null
408
+ answer would be a wrong answer wearing the shape of a right one.
409
+
410
+ A function answers null where any argument is null, the way Trino's do. An argument left out takes
411
+ its default and an argument written as `NULL` does not, so `regexp_extract(url, 'a', NULL)` answers
412
+ null where `regexp_extract(url, 'a')` reads the whole match.
413
+
414
+ ## Tables a query names
415
+
416
+ A query's `FROM` and `JOIN` clauses are read, and each table they name is looked for in the Glue
417
+ Data Catalog for the same account and region. A query naming a table the catalog has no entry for
418
+ reaches `FAILED`, carrying Athena's own reason.
419
+
420
+ That catches a stack whose table never deployed, a database renamed on one side only, and a typo.
421
+ Each of them answers a declared result otherwise, and the test written to catch it passes.
422
+
423
+ ```typescript sim-athena-table-resolution
424
+ /**
425
+ * A query naming a table the Data Catalog has never heard of.
426
+ */
427
+
428
+ import { SimAws } from "@kensio/yulin";
429
+
430
+ const simAws = new SimAws();
431
+
432
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-results" } });
433
+ await simAws.athena().createWorkGroup({
434
+ input: {
435
+ Name: "rainlytics",
436
+ Configuration: {
437
+ ResultConfiguration: { OutputLocation: "s3://rainlytics-results/q/" },
438
+ },
439
+ },
440
+ });
441
+
442
+ simAws
443
+ .glue()
444
+ .createDatabase({ input: { DatabaseInput: { Name: "rainlytics" } } });
445
+ simAws.glue().createTable({
446
+ input: {
447
+ DatabaseName: "rainlytics",
448
+ TableInput: { Name: "access_logs" },
449
+ },
450
+ });
451
+
452
+ const started = await simAws.athena().startQueryExecution({
453
+ input: {
454
+ QueryString: "SELECT cs_uri_stem FROM rainlytics.acess_logs",
455
+ WorkGroup: "rainlytics",
456
+ },
457
+ });
458
+
459
+ await simAws.backgroundTasksComplete();
460
+
461
+ const execution = await simAws.athena().getQueryExecution({
462
+ input: { QueryExecutionId: started.QueryExecutionId },
463
+ });
464
+
465
+ // "FAILED"
466
+ console.log(execution.QueryExecution?.Status?.State);
467
+ // names awsdatacatalog.rainlytics.acess_logs
468
+ console.log(execution.QueryExecution?.Status?.StateChangeReason);
469
+ ```
470
+
471
+ An unqualified name resolves against `QueryExecutionContext.Database`. A query naming neither fails
472
+ saying a schema has to be specified, as Athena does.
473
+
474
+ Resolution starts once the catalog holds a database. A simulation where nothing created one answers
475
+ every query from its declaration. That is how simulated Athena behaved before this existed.
476
+
477
+ A name a `WITH` clause defined is left alone, and so are a table alias, a subquery and whatever
478
+ `UNNEST` produces. `information_schema` resolves without a catalog entry, because Athena serves that
479
+ schema itself.
480
+
481
+ Nothing here plans the query. Reading the table names is a scan, and a statement
482
+ it cannot follow runs the way it always did. That covers a statement writing data, a query against a
483
+ federated catalog, and anything the scan gets lost in.
484
+
485
+ ## Partition projection
486
+
487
+ A table configuring [partition projection](https://docs.aws.amazon.com/athena/latest/ug/partition-projection.html "AWS partition projection docs") has that configuration read when a query runs against it. The four projection types are `enum`, `integer`, `date` and `injected`, and all four are expanded into the partition values the table projects.
488
+
489
+ Projection lives entirely in a Glue table's `Parameters`, which Glue accepts whatever they say. Athena is what reads them, so a mistake in one shows up as a failed query rather than a failed deploy. That is where it shows up here too.
490
+
491
+ ```typescript sim-athena-partition-projection
492
+ /**
493
+ * A table whose projected date range names a month that does not exist.
494
+ */
495
+
496
+ import { SimAws } from "@kensio/yulin";
497
+
498
+ const simAws = new SimAws();
499
+
500
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-results" } });
501
+ await simAws.athena().createWorkGroup({
502
+ input: {
503
+ Name: "rainlytics",
504
+ Configuration: {
505
+ ResultConfiguration: { OutputLocation: "s3://rainlytics-results/q/" },
506
+ },
507
+ },
508
+ });
509
+
510
+ simAws.glue().createDatabase({
511
+ input: { DatabaseInput: { Name: "rainlytics" } },
512
+ });
513
+ simAws.glue().createTable({
514
+ input: {
515
+ DatabaseName: "rainlytics",
516
+ TableInput: {
517
+ Name: "access_logs",
518
+ PartitionKeys: [{ Name: "day", Type: "string" }],
519
+ StorageDescriptor: { Location: "s3://rainlytics-logs/cloudfront/" },
520
+ Parameters: {
521
+ "projection.enabled": "true",
522
+ "projection.day.type": "date",
523
+ "projection.day.format": "yyyy-MM-dd",
524
+ "projection.day.range": "2026-13-01,NOW",
525
+ // eslint-disable-next-line no-template-curly-in-string
526
+ "storage.location.template": "s3://rainlytics-logs/logs/${day}/",
527
+ },
528
+ },
529
+ },
530
+ });
531
+
532
+ const started = await simAws.athena().startQueryExecution({
533
+ input: {
534
+ QueryString: "SELECT cs_uri_stem FROM rainlytics.access_logs",
535
+ WorkGroup: "rainlytics",
536
+ },
537
+ });
538
+
539
+ await simAws.backgroundTasksComplete();
540
+
541
+ const execution = await simAws.athena().getQueryExecution({
542
+ input: { QueryExecutionId: started.QueryExecutionId },
543
+ });
544
+
545
+ // "FAILED"
546
+ console.log(execution.QueryExecution?.Status?.State);
547
+ // INVALID_TABLE_PROPERTY, naming day and the bound it could not read
548
+ console.log(execution.QueryExecution?.Status?.StateChangeReason);
549
+ ```
550
+
551
+ A query fails where a partition key carries no `projection.<key>.type`, where a range fails to read, where an `integer` range carries `NOW`, where `storage.location.template` leaves out one of the projected keys, and where an `injected` column goes unconstrained.
552
+
553
+ `NOW` is read against the simulated clock, along with an offset such as `NOW-3YEARS`. A test that froze time projects the same partitions on every run.
554
+
555
+ The `WHERE` clause narrows what is projected. `day = '2026-08-25'` and `day IN ('a', 'b')` are the two forms read, and a query carrying `OR` anywhere is left unnarrowed. A `NOT` leaves the columns it reaches unnarrowed and the rest alone. A query pruning on the day and filtering bots out of what is left goes on pruning on the day. A filter left unread keeps every projected partition in. That is always the safe answer.
556
+
557
+ A table with projection on and no `storage.location.template` gets the Hive layout under its own location, as `<location>/day=2026-08-25/`.
558
+
559
+ ## Names are folded to lower case
560
+
561
+ Athena accepts mixed case in a query and lower cases the names when it executes it. A query naming `Rainlytics.Access_Logs` resolves against the table the catalog holds as `rainlytics.access_logs`, and so does one naming `"Rainlytics"."Access_Logs"`. Quoting an identifier says what characters it may hold rather than what case it keeps.
562
+
563
+ The database in a query's execution context folds the same way, and a refusal names the table the way Athena went looking for it. Simulated [Glue](https://yulinsim.dev/services/glue/ "Simulated Glue usage docs") folds a database and a table name when it stores one, so both ends of the lookup agree.
564
+
565
+ Column names are left alone here. Real Athena folds those too, and nothing in this simulation resolves a column by name.
566
+
567
+ ## Registered partitions
568
+
569
+ A table that registers its partitions rather than projecting them is read from the catalog. A query against one reads a prefix per registered partition, taken from that partition's own storage descriptor location, and the `WHERE` clause narrows them the way it narrows projected ones.
570
+
571
+ ```typescript sim-athena-registered-partitions
572
+ /**
573
+ * A query over a table whose partitions the catalog holds.
574
+ */
575
+
576
+ import {
577
+ CreateDatabaseCommand,
578
+ CreatePartitionCommand,
579
+ CreateTableCommand,
580
+ } from "@aws-sdk/client-glue";
581
+
582
+ import { SimAws } from "@kensio/yulin";
583
+
584
+ const simAws = new SimAws();
585
+ const glue = simAws.glue();
586
+
587
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-logs" } });
588
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-results" } });
589
+
590
+ glue.createDatabase(
591
+ new CreateDatabaseCommand({ DatabaseInput: { Name: "rainlytics" } }),
592
+ );
593
+ glue.createTable(
594
+ new CreateTableCommand({
595
+ DatabaseName: "rainlytics",
596
+ TableInput: {
597
+ Name: "access_logs",
598
+ PartitionKeys: [{ Name: "day", Type: "string" }],
599
+ StorageDescriptor: { Location: "s3://rainlytics-logs/logs/" },
600
+ },
601
+ }),
602
+ );
603
+
604
+ for (const day of ["2026-08-25", "2026-08-26"]) {
605
+ glue.createPartition(
606
+ new CreatePartitionCommand({
607
+ DatabaseName: "rainlytics",
608
+ TableName: "access_logs",
609
+ PartitionInput: {
610
+ Values: [day],
611
+ StorageDescriptor: { Location: `s3://rainlytics-logs/logs/${day}/` },
612
+ },
613
+ }),
614
+ );
615
+
616
+ await simAws.s3().putObject({
617
+ input: {
618
+ Bucket: "rainlytics-logs",
619
+ Key: `logs/${day}/part-0.json`,
620
+ Body: "x".repeat(1000),
621
+ },
622
+ });
623
+ }
624
+
625
+ const { QueryExecutionId } = await simAws.athena().startQueryExecution({
626
+ input: {
627
+ QueryString:
628
+ "SELECT url FROM rainlytics.access_logs WHERE day = '2026-08-26'",
629
+ ResultConfiguration: { OutputLocation: "s3://rainlytics-results/q/" },
630
+ },
631
+ });
632
+
633
+ await simAws.backgroundTasksComplete();
634
+
635
+ const execution = await simAws
636
+ .athena()
637
+ .getQueryExecution({ input: { QueryExecutionId } });
638
+
639
+ // 1000
640
+ console.log(execution.QueryExecution?.Statistics?.DataScannedInBytes);
641
+ ```
642
+
643
+ A partition registered with no location of its own falls back to the Hive layout under the table's location, as `<location>/day=2026-08-26/`. A partition registered somewhere else entirely is read there, which is something a table location alone could never reach.
644
+
645
+ Projection wins where a table carries both. Real Athena stops reading the catalog's partitions once `projection.enabled` is true, and that is the whole reason for turning it on.
646
+
647
+ A table with neither reads the location in its storage descriptor, and the query reads everything under it.
648
+
649
+ ## What a query scans
650
+
651
+ A query's bytes scanned are measured from the objects it reads. The prefixes come from the table's
652
+ partition projection, from the partitions the catalog holds against it, or from the location in its
653
+ storage descriptor where a table has neither. Every object under each one counts.
654
+
655
+ ```typescript sim-athena-scanned-bytes
656
+ /**
657
+ * A query measured against the objects a test seeded.
658
+ */
659
+
660
+ import { SimAws } from "@kensio/yulin";
661
+
662
+ const simAws = new SimAws();
663
+
664
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-results" } });
665
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-logs" } });
666
+ await simAws.athena().createWorkGroup({
667
+ input: {
668
+ Name: "rainlytics",
669
+ Configuration: {
670
+ ResultConfiguration: { OutputLocation: "s3://rainlytics-results/q/" },
671
+ },
672
+ },
673
+ });
674
+
675
+ simAws.glue().createDatabase({
676
+ input: { DatabaseInput: { Name: "rainlytics" } },
677
+ });
678
+ simAws.glue().createTable({
679
+ input: {
680
+ DatabaseName: "rainlytics",
681
+ TableInput: {
682
+ Name: "access_logs",
683
+ StorageDescriptor: { Location: "s3://rainlytics-logs/logs/" },
684
+ },
685
+ },
686
+ });
687
+
688
+ await simAws.s3().putObject({
689
+ input: {
690
+ Bucket: "rainlytics-logs",
691
+ Key: "logs/part-0.json",
692
+ Body: "x".repeat(1200),
693
+ },
694
+ });
695
+
696
+ const started = await simAws.athena().startQueryExecution({
697
+ input: {
698
+ QueryString: "SELECT cs_uri_stem FROM rainlytics.access_logs",
699
+ WorkGroup: "rainlytics",
700
+ },
701
+ });
702
+
703
+ await simAws.backgroundTasksComplete();
704
+
705
+ const execution = await simAws.athena().getQueryExecution({
706
+ input: { QueryExecutionId: started.QueryExecutionId },
707
+ });
708
+
709
+ // 1200
710
+ console.log(execution.QueryExecution?.Statistics?.DataScannedInBytes);
711
+ ```
712
+
713
+ A query filtering on a partition key reads only the prefixes that filter allows, whether the
714
+ partitions were projected or registered. A partitioned table then scans less than an unpartitioned
715
+ one, and a test can prove it.
716
+
717
+ The listing goes through simulated S3 under the caller that started the query, as Athena reads a
718
+ table's data under the identity that asked for it. A caller who cannot read the Bucket fails the
719
+ query, and the reason names it. A table pointing at a Bucket the simulation never made scans
720
+ nothing. A table nobody put data behind is one nobody set up to measure.
721
+
722
+ A declared `bytesScanned` wins where a test writes one down. That keeps a test able to drive the
723
+ guardrail without seeding an object.
724
+
725
+ ## The bytes scanned cutoff
726
+
727
+ A query whose bytes scanned pass the workgroup's `BytesScannedCutoffPerQuery` reaches `FAILED`. This
728
+ is the one guardrail this simulation enforces for real, and it is enforced against what the objects
729
+ under the query's prefixes come to.
730
+
731
+ ```typescript sim-athena-bytes-scanned-cutoff
732
+ /**
733
+ * A workgroup's cost guardrail refusing a query that scans too much.
734
+ */
735
+
736
+ import { SimAws } from "@kensio/yulin";
737
+
738
+ const simAws = new SimAws();
739
+
740
+ await simAws.s3().createBucket({ input: { Bucket: "rainlytics-results" } });
741
+ await simAws.athena().createWorkGroup({
742
+ input: {
743
+ Name: "rainlytics",
744
+ Configuration: {
745
+ BytesScannedCutoffPerQuery: 10_000_000,
746
+ ResultConfiguration: { OutputLocation: "s3://rainlytics-results/q/" },
747
+ },
748
+ },
749
+ });
750
+
751
+ const unpartitioned = "SELECT * FROM rainlytics.access_logs";
752
+
753
+ simAws
754
+ .athena()
755
+ .results()
756
+ .onQuery(unpartitioned, { rows: [["4213"]], bytesScanned: 40_000_000 });
757
+
758
+ const started = await simAws.athena().startQueryExecution({
759
+ input: { QueryString: unpartitioned, WorkGroup: "rainlytics" },
760
+ });
761
+
762
+ await simAws.backgroundTasksComplete();
763
+
764
+ const execution = await simAws.athena().getQueryExecution({
765
+ input: { QueryExecutionId: started.QueryExecutionId },
766
+ });
767
+
768
+ // "FAILED"
769
+ console.log(execution.QueryExecution?.Status?.State);
770
+
771
+ // Names the limit and what the query scanned.
772
+ console.log(execution.QueryExecution?.Status?.StateChangeReason);
773
+ ```
774
+
775
+ `GetQueryExecution` reports the bytes scanned in `Statistics` whichever way the query ended. A
776
+ caller costing a mistake can still read it.
777
+
778
+ A repeated `ClientRequestToken` answers with the execution it started the first time. A client
779
+ retrying after a timeout is charged once.
780
+
781
+ ## Where results go
782
+
783
+ Results are written to the output location as a CSV object named for the execution,
784
+ `<prefix>/<QueryExecutionId>.csv`. `GetQueryExecution` reports the object itself, not the prefix
785
+ it sits under.
786
+
787
+ A workgroup with `EnforceWorkGroupConfiguration` set sends results to its own location whatever the
788
+ request asked for. Without it, a request naming a `ResultConfiguration.OutputLocation` wins and a
789
+ request naming none falls back to the workgroup's. A query with neither is refused before it is
790
+ queued.
791
+
792
+ The write goes through simulated S3 as the caller that started the query, since Athena writes a
793
+ result under the identity that asked for it. A caller who cannot write to the Bucket gets a `FAILED`
794
+ execution saying so.
795
+
796
+ ## Reading a workgroup back
797
+
798
+ `GetWorkGroup`, `ListWorkGroups`, `CreateWorkGroup`, `UpdateWorkGroup` and `DeleteWorkGroup` all
799
+ work through the SDK, and `simAws.athena().findWorkGroup(name)` reads one back without going through
800
+ a command and its authorization.
801
+
802
+ ```typescript sim-athena-work-group-accessor
803
+ /**
804
+ * Reading a simulated workgroup's cutoff without an SDK command.
805
+ */
806
+
807
+ import { SimAws } from "@kensio/yulin";
808
+
809
+ const simAws = new SimAws();
810
+
811
+ await simAws.athena().createWorkGroup({
812
+ input: {
813
+ Name: "rainlytics",
814
+ Configuration: { BytesScannedCutoffPerQuery: 512 },
815
+ },
816
+ });
817
+
818
+ // 512
819
+ console.log(
820
+ simAws.athena().findWorkGroup("rainlytics")?.bytesScannedCutoffPerQuery,
821
+ );
822
+ ```
823
+
824
+ `UpdateWorkGroup` merges field by field, as real Athena does. A field the update leaves out
825
+ keeps what the workgroup already had, and clearing one takes its own removal flag:
826
+ `RemoveBytesScannedCutoffPerQuery`, or `RemoveOutputLocation` and its siblings inside
827
+ `ResultConfigurationUpdates`.
828
+
829
+ ## The primary workgroup
830
+
831
+ Every account and region scope starts with `primary`, which real Athena makes with the account. A
832
+ `CreateNamedQuery` or `ListNamedQueries` naming no workgroup lands there, and `primary` cannot be
833
+ deleted.
834
+
835
+ Deleting any other workgroup that still holds named queries needs `RecursiveDeleteOption`, which
836
+ takes them with it. A stack teardown always deletes recursively. A stack that made both goes down
837
+ in one go.
838
+
839
+ ## Authorization
840
+
841
+ Every command is authorized against the workgroup ARN,
842
+ `arn:aws:athena:<region>:<account>:workgroup/<name>`. Real Athena gives a named query no ARN of its
843
+ own and authorizes work on one against the workgroup it belongs to. This asks the same question.
844
+ `ListWorkGroups` names no workgroup, so IAM evaluates it against `*`.
845
+
846
+ ## Available functionality
847
+
848
+ - Query executions, moving through `QUEUED` and `RUNNING` to `SUCCEEDED`, `FAILED` or `CANCELLED`
849
+ - `StartQueryExecution`, `GetQueryExecution`, `GetQueryResults` and `StopQueryExecution`
850
+ - A `SELECT` run for real over JSON lines and CSV objects in simulated S3, answered by SQLite
851
+ - `UNNEST` over an array or a map column, with `WITH ORDINALITY` where a query wants the position
852
+ - Trino's date, JSON, array, string and URL functions, with `current_timestamp` reading the
853
+ simulated clock
854
+ - Declared results, matched on the query text, ahead of the engine for one statement and behind it
855
+ for everything else
856
+ - Table names in `FROM` and `JOIN` resolved against the simulated Glue Data Catalog
857
+ - Partition projection evaluated, covering `enum`, `integer`, `date` and `injected`
858
+ - Bytes scanned measured from the objects under the prefixes a query reads
859
+ - `BytesScannedCutoffPerQuery` enforced against that measurement, or against a declared figure
860
+ - Result sets written to the workgroup's output location as CSV, under the caller's own identity
861
+ - Workgroups, scoped by account and region, with `primary` there from the start
862
+ - `CreateWorkGroup`, `GetWorkGroup`, `UpdateWorkGroup`, `DeleteWorkGroup` and `ListWorkGroups`
863
+ - Named queries, with `CreateNamedQuery`, `GetNamedQuery`, `BatchGetNamedQuery`, `ListNamedQueries`
864
+ and `DeleteNamedQuery`
865
+ - `AWS::Athena::WorkGroup`, answering `Ref` with the name and `Fn::GetAtt` with `CreationTime`
866
+ - `AWS::Athena::NamedQuery`, answering `Ref` and `Fn::GetAtt NamedQueryId` with the query id
867
+ - IAM authorization against the workgroup ARN
868
+ - Paging on both listings, by `MaxResults` and `NextToken`
869
+
870
+ ## Limitations
871
+
872
+ Current documented limitations:
873
+
874
+ - With the engine off, no SQL is evaluated. Nothing plans or runs a query, no S3 object is read to
875
+ answer one, and every row comes from a declaration a test wrote. Simulated Athena will therefore
876
+ accept a query real Athena would reject.
877
+ - With the engine on, the statement is read as Athena by `node-sql-parser`, written back out for
878
+ SQLite and run there. Around one query in twenty is turned down at one of those two steps and
879
+ falls back to its declared result. `GROUPING SETS` is the measured case, which the parser's
880
+ Athena grammar refuses outright.
881
+ - One `UNNEST` per statement is rewritten onto `json_each`, and it has to be a `CROSS JOIN`. A
882
+ statement carrying two, one under a `LEFT JOIN`, one whose alias names no columns, and one
883
+ selecting every column all fall back. So does `WITH ORDINALITY` over a map, since `json_each`
884
+ gives a map's keys rather than its positions.
885
+ - `UNNEST` over a `ROW` or a struct array falls back. The element needs field access and the
886
+ flattened column is JSON text here.
887
+ - The Trino function library reaches as far as the table under
888
+ [the functions a statement can call](#the-functions-a-statement-can-call). A query reaching for
889
+ anything else Trino has and SQLite lacks falls back.
890
+ - `filter` and the other functions taking a lambda are absent, and deliberately unshimmed. SQLite
891
+ reads `->` as its own JSON operator. A name registered for one of them would leave the lambda to
892
+ be read as that operator and answer something, where an absent name fails and falls back.
893
+ - `date_add` and `date_diff` count a calendar month by whether moving the first instant reached the
894
+ second. That is how `java.time` counts and so how Trino does. The thirty-first of January to the
895
+ twenty-eighth of February is a whole month.
896
+ - `at_timezone` answers with the wall clock of the zone and no zone on it, since a timestamp here
897
+ carries none. Trino answers with a timestamp carrying the zone.
898
+ - `json_parse`, `regexp_extract` and `regexp_replace` answer null over text they cannot read. Trino
899
+ fails the query. The `url_extract` family answers null too, and there Trino answers null as well,
900
+ since each of those functions is declared never to fail.
901
+ - `json_extract` answers with JSON, so a string comes back quoted. SQLite's own unwraps it, and a
902
+ statement comparing the answer against a bare string matches on one and not the other.
903
+ - A timestamp carrying a numeric UTC offset falls outside the date functions, and so does one
904
+ written finer than the millisecond. A value written with a `Z` or with no zone at all reads as
905
+ UTC, and a value the date functions cannot read turns the query down.
906
+ - A JSON number beyond about fifteen significant digits loses the digits past that, wherever the
907
+ engine reads JSON. An identifier of that size in a JSON lines object comes back rounded, and a
908
+ filter on it can then match the wrong row.
909
+ - The `url_extract` family splits a URI reference the way RFC 3986 writes it, so a reference with no
910
+ scheme reads, as `/reports/august?tenant=acme`. That is the shape a CloudFront log holds, since it
911
+ carries the path and the query in columns of their own. Text carrying a character RFC 2396 leaves
912
+ out, or a percent naming no byte, answers null.
913
+ - A percent escape in the path, the query or the fragment comes back still escaped, and a parameter
914
+ name is matched decoded. Trino reads a URL the way Java does and decodes each of those.
915
+ - `url_decode` answers null over text it cannot read back. Trino raises over an escape that names
916
+ no byte, such as `%zz`, and writes a replacement character where the escapes name bytes that are
917
+ no UTF-8, such as `%C3%28`. `url_extract_parameter` decodes its own answer, the way Trino's does.
918
+ A `url_decode` written after it reads the escapes a second time.
919
+ - `regexp_replace` takes Trino's own spelling for a named group and an escaped dollar, `${name}`
920
+ and `\$`. The rest of Java's replacement syntax is not translated.
921
+ - A pattern runs under JavaScript's `RegExp` rather than under Joni. `(?i)`, `(?m)` and `(?s)` at
922
+ the head of a pattern are lifted onto the expression and apply to the whole of it, and the scoped
923
+ `(?i:...)` runs as written. `(?x)` and a flag group written anywhere but the head turn the pattern
924
+ down, since JavaScript can turn no flag on part way through one.
925
+ - The date and time functions work on the ISO-8601 text a JSON or CSV object carries. A column
926
+ written any other way gets whatever slicing that text comes to.
927
+ - A `varbinary` in a result row reads as its bytes decoded as UTF-8. A digest read that way loses
928
+ whatever in it was no UTF-8. A query answering with one wants `to_hex` or `to_base64` around it.
929
+ - `from_hex` and `from_base64` turn the query down over text the encoding cannot carry. Trino fails
930
+ the query, and reading as much of the text as parses would answer with bytes nobody wrote.
931
+ - `from_utf8` with a replacement named turns the query down where the bytes already carry the
932
+ replacement character. Trino writes the replacement only where a sequence was broken, and nothing
933
+ survives decoding to tell one of those apart from a character the bytes held.
934
+ - `spooky_hash_v2_32` and `spooky_hash_v2_64` are absent, and so are the other Trino hashes with no
935
+ entry in the table above.
936
+ - `approx_distinct` and `approx_percentile` are computed exactly. The simulation is more accurate
937
+ than AWS here, and at the scale a test seeds the difference cannot show.
938
+ - Three classes of expression the engine accepts are ones real Athena refuses. `1 / 0` answers
939
+ null, `CAST('abc' AS INTEGER)` answers 0, and `1 || 'x'` answers `'1x'`. Each of them fails a
940
+ real query.
941
+ - A column of text reaching `sha256` or any of the other byte-only functions is hashed as its UTF-8
942
+ bytes. Athena refuses the argument and fails the query, and a test writing one gets an answer
943
+ where real Athena would have given an error. SQLite has no analysis to refuse the argument with,
944
+ and a Glue `binary` column arrives here as text, so refusing it would turn down a query Athena
945
+ runs.
946
+ - `try_cast` runs as a plain cast. `try_cast('abc' AS integer)` therefore answers 0 where real
947
+ Athena answers null, which is the same forgiving direction as the cast above. Reading the
948
+ statement without the rewrite would turn the whole query down.
949
+ - A `decimal` column is held as a double. A value carrying more than about fifteen significant
950
+ digits loses the ones past that, and a filter, a sum or a group on it can then answer
951
+ differently from Athena's exact arithmetic.
952
+ - A declaration that fails the query wins from any tier, the engine included. `failsWith` is a
953
+ statement about the query rather than about its rows, so a workgroup rule or a default carrying
954
+ one fails every query it covers whether or not the engine could have answered.
955
+ - Parquet and ORC are absent. A table declaring either falls back to its declared result, and so
956
+ does a table declaring no SerDe at all.
957
+ - A null in a result row reads as an empty string. Real Athena leaves the value out of the row.
958
+ - A computed boolean reads as `1` and `0`. The Glue column type is what makes a boolean column read
959
+ as `true` and `false`, and an expression has no column type behind it.
960
+ - An expression nobody named is called `_col0` upward, as Athena calls one. An alias that needed
961
+ quotes around it is renamed the same way.
962
+ - The engine reads every object under the prefixes a query reaches and holds the rows in memory.
963
+ That suits the fixture-sized data a test seeds and nothing larger.
964
+ - A caller who can list a Bucket and cannot read its objects gets the declared result. A listing
965
+ refused by IAM fails the query, and that is what the bytes scanned measurement exposes.
966
+ - The table names in `FROM` and `JOIN` are the one part of a query that is read, and they are found
967
+ by a scan. A statement the scan cannot follow runs with its tables never
968
+ looked for, which covers `CREATE TABLE AS SELECT`, `INSERT INTO`, `MSCK REPAIR TABLE`, `SHOW` and
969
+ `DESCRIBE`. Only the table is resolved. Columns, types and everything else a planner checks stay
970
+ out of reach.
971
+ - Table resolution starts once the Data Catalog holds a database. Every query in a simulation
972
+ holding none is answered from its declaration.
973
+ - Partition projection is expanded and checked. The objects under the prefixes it comes to are
974
+ listed for their sizes and never opened. A projection naming partitions the Bucket never held
975
+ scans nothing and passes.
976
+ - A projected date's format understands `y`, `M`, `d`, `H`, `m` and `s`, which covers the patterns a
977
+ partition path is written in. The wider `SimpleDateFormat` grammar stays out of reach.
978
+ - The `WHERE` clause is read for `column = 'value'` and `column IN ('a', 'b')` only, and a query
979
+ carrying `OR` anywhere is left unnarrowed. A partition narrowed less than real Athena would narrow
980
+ it costs a wider scan here, and the answer stays the same.
981
+ - How far a `NOT` reaches is read without parsing. A column it could not really have reached is left
982
+ unnarrowed as well, which costs that same wider scan.
983
+ - An infix `NOT`, as in `day NOT IN ('a')`, leaves its own column narrowed by whatever else the
984
+ `WHERE` says. That is always a superset of the partitions Athena reads, and the query applies the
985
+ exclusion itself.
986
+ - A table projecting more than 20,000 partitions fails the query. Real Athena has a limit of its own
987
+ and this one is the simulation's.
988
+ - An `enum` projection has the spaces around each of its values trimmed, so `a, b` is two values
989
+ rather than `a` and ` b`. Whether real Athena trims them is unverified.
990
+ - An `integer` projection takes bounds inside JavaScript's safe integer range, and a bound beyond it
991
+ is refused. Athena's own range runs to the signed 64 bit limit.
992
+ - `MILLISECONDS` is absent from the interval units, and a pattern carrying `S` is read as literal
993
+ text. A partition path written to the millisecond falls outside this.
994
+ - Partitions registered through the Glue Partitions API are read. Registering one from Athena is
995
+ absent, so `MSCK REPAIR TABLE` and `ALTER TABLE ADD PARTITION` register nothing and a test that
996
+ wants partitions puts them in the catalog through Glue.
997
+ - A registered partition's own columns are ignored. The table's schema is what every partition is
998
+ read with, so a table whose schema changed part way through its life reads the newer columns for
999
+ the older partitions too.
1000
+ - A partition registered with no location, against a table with none either, is read as having
1001
+ nowhere to look and contributes no prefix.
1002
+ - A query naming a catalog other than `awsdatacatalog` runs with its tables never looked for.
1003
+ Federated catalogs and `AWS::Athena::DataCatalog` fall outside this simulation.
1004
+ - Bytes scanned are the total size of every object under the prefixes a query reads. Real Athena
1005
+ reads only the columns a query asks for and counts compressed bytes, so it reports a smaller
1006
+ figure for the same data in a columnar format. A cutoff test written here therefore fires on less
1007
+ data than production would need.
1008
+ - A declared `bytesScanned` overrides the measurement entirely.
1009
+ - A query that exceeds the cutoff reaches `FAILED` here. AWS documents the per-query data usage
1010
+ control as cancelling a query. So a client matching on `FAILED` passes here and misses the
1011
+ cancellation in production, and one matching on `CANCELLED` fails here while being right in
1012
+ production. Match on the state being terminal, and read `StateChangeReason` for the why.
1013
+ - `GetQueryResults` pages up to 1000 rows, as Athena does. The listings of workgroups and named
1014
+ queries stop at 50, which is their own documented maximum.
1015
+ - `ListQueryExecutions`, `BatchGetQueryExecution` and `GetQueryRuntimeStatistics` are absent, along
1016
+ with query result reuse, result encryption, `CREATE TABLE AS SELECT` and `INSERT INTO`. A
1017
+ statement that writes data runs with its tables never looked for and answers from a declaration.
1018
+ - Real Athena's own floor for the bytes scanned cutoff is 10MB. This simulation takes any whole
1019
+ number of bytes from 1 up, putting the guardrail wherever the query a test is exercising needs
1020
+ it. A cutoff of zero or a fraction is still refused.
1021
+ - `ResultConfiguration` is stored and returned in full, and `OutputLocation` is the only field that
1022
+ means anything. The encryption configuration, the ACL configuration and the expected bucket owner
1023
+ come back as they were set, and stay unapplied.
1024
+ - `EnforceWorkGroupConfiguration` decides the output location and nothing else. Real Athena's
1025
+ override rules are per field and cover the encryption configuration, the expected bucket owner
1026
+ and the ACL as well, and a request naming one of those has it taken whatever the workgroup says.
1027
+ - `PublishCloudWatchMetricsEnabled`, `RequesterPaysEnabled` and `EngineVersion` are stored and
1028
+ returned unacted on. No metric is published, no requester is billed and no engine is chosen.
1029
+ - A query's `EngineExecutionTimeInMillis` is measured on the simulated clock. A query that ran
1030
+ between two ticks of a frozen clock took no time at all.
1031
+ - A named query's SQL goes unparsed. Text an engine would reject is stored and handed back exactly
1032
+ as it was sent.
1033
+ - `AWS::Athena::DataCatalog`, `AWS::Athena::PreparedStatement` and
1034
+ `AWS::Athena::CapacityReservation` fall outside this simulation. A template declaring one is
1035
+ recorded on the stack's `skippedResources` and the rest of the stack deploys.
1036
+ - A named query's `Database` is a string simulated Athena leaves unresolved. A saved query naming a
1037
+ database the Data Catalog has never heard of is stored all the same. Resolution happens when a
1038
+ query runs rather than when one is saved.
1039
+ - Workgroup tags, prepared statements, capacity reservations, query result reuse and Athena for
1040
+ Spark are all absent.
1041
+ - A `WorkGroupConfiguration` setting this simulation has no answer for, such as `ExecutionRole`,
1042
+ `AdditionalConfiguration` or `IdentityCenterConfiguration`, is recorded on the stack's
1043
+ `ignoredProperties` and the workgroup deploys without it.
1044
+ - Athena has no HTTP API under `serveSimAws`.